Pull to refresh

ORM на три звена. В 120 раз быстрее SQL?

Level of difficultyMedium
Reading time45 min
Views6.9K

Нет, речь не про кэш в памяти. Так было бы слишком просто. У нас сегодня будет препарирован ORM, который честно запрашивает данные у реляционной СУБД, маппит в объекты, подключает связи и отдаёт в логику приложения в виде объектов. И всё на порядки быстрее, чем прямой запрос из кода приложения.

Да, здесь есть нюанс. Об этом нюансе, а также о том, зачем я написал в пятый раз кастомный ORM и будет эта статья. Эта разработка тесно переплетена с моей личной историей, когда я переходил с одной работы на другую, а затем был уволен. Я не хочу оставлять сухой технический текст, поэтому эта статья будет скорее рассказом моей работе в этой компании.

Код в статью я старался включать по минимуму. Он точно не полный и возможно ошибочный, потому что дорабатывался по мере написания статьи. Полный и исправленный вариант будет доступен по ссылке в конце статьи.

Летом 2022 года я устроился в другую компанию в качестве десктоп-разработчика с большим стажем. В причины перехода я углубляться не буду, они более чем стандартные.

Там как раз внутренняя система была двухзвенной, с клиентом на C# winforms и базой данных Oracle в облаке яндекса. Это важная деталь. Был главный офис и несколько поменьше, всем нужен был доступ к данным, поэтому там была какая-то железка, ходящая в VPN с облачным сервером.

Предыдущая команда, которая написала информационную систему, почему-то в полном составе покинула компанию за год до моего прихода. Насколько я понял, их там было четверо. Они выбрали именно такой стек технологий и написали всё с нуля. Почему они ушли, мне так и не сказали. Рассказывали аналогию про тонущий корабль, но я так её и не понял. Наверное, с какой-то притчей связано.

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

Устройство

Система была построена следующим образом: логика в БД, а шарп использовался как фронтенд. Для показа табличных данных берём запрос из ресурсов винформс, ну или вьюшку с той стороны, а результат получаем в DataTable, которые целиком передаётся в грид девэкспресса. И этот подход вызывал дикий перерасход памяти, так как все поля хранятся в виде object. Например Int32 будет занимать в забоксенном виде 24 байта вместо четырёх.

Логика в шарпе была в тех местах, где объекты изменялись или добавлялись. Это были обычные формы. Обычная логика для таких мест - получить данные, распихать по комбобоксам и текстбоксам, потом в обработчике OnOK() собрать всё, провалидировать, отправить в БД. Ничего такого сверхъестественного. Только очень неряшливо было сделано. Например, на каждое изменение поля формы могло вызваться перезаполнение полей, причём зачем-то дважды. Возможно, это для каких-то полей имело смысл, но я этого так и не узнал. Перезаполнение - это перезапрос данных у БД, пересчёт всего считаемого и распихивание свежих данных по субконтролам. Впринципе, понятно - ребята просто не заморачивались с такими мелочами. Работает же.

Несмотря на обилие комментариев, их полезность стремится к нулю, потому что они описывают, что делает следующая строчка (я это и так вижу), а не почему так сделано. А иногда комментарии просто врут. То есть буквально код выглядит вот так:

// если передавали проект - выберем его
if (LocalProjectCode != null)
    cbProject.SetEditValue(Convert.ToDecimal(LocalProjectCode));
// если передавали проект - выберем его
if (LocalWarehouseCode != null)
    cbWarehouse.SetEditValue(Convert.ToDecimal(LocalWarehouseCode));
// выполноим запрос и поддтянем данные
qDates.Open();
//
this.MdiParent = AMdiParent;

(авторская орфография сохранена)

Общее впечатление о коде: грязно. Очень грязно. Кода много, он писался второпях и никогда не приводился в порядок. Комментарии только увеличивают энтропию кода, не привнося упорядоченности.

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

База данных

Я никогда раньше не сталкивался с тем, что всю логику приложения выносят в базу данных. Я и с ораклом-то никогда раньше не сталкивался. Ну БД и БД. Там циферки какие-то и буковки хранятся, что там ещё может быть?

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

Внутри вьюшех - хаос из CROSS LATERAL JOIN, UNION, WITHIN GROUP, CONNECT BY и всё обильно присыпано NVL, LISTAGG, REGEXP и COALESCE. Никаких промежуточных переменных, никаких комментариев, только суровый декларативный синтаксис. В хранимках немного лучше, там есть комментарии, но в том же стиле, что и в коде шарпа. Хранимки в основном занимаются внесением сущностей в БД и их изменением.

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

Кое-что мне в самой структуре базы не нравилось по субъективным причинам. Например сквозной айди через все таблички. Есть вспомогательная табличка на пять строк, айдишники там идут так: 97, 98, 16962746234, 16962746235, 45712155667. Оно, конечно, дело вкуса, но на эту маленькую табличку ссылается двухколоночная таблица-связка в 40 млн записей. И вместо байта на один айди там будет бигинт в 8 байт (ну я так, примерно). Помножить оверхед на 40 млн - и получится уже существенно. А база данных там была реально распухшей, и это было проблемой.

Кстати, айдишник там везде назывался "Code". Это меня слегка раздражало, как и постоянное использование "DAT" вместо "DATE". И почему-то было смешно читать название "DAT_SUPPLY".

Я ещё обнаружил репозиторий с файлами Liquibase, куда предыдущая команда пару раз закоммитила структуру БД вместе с хранимками. К сожалению, потом они забили на коммиты в дальнейшем и актуальная версия хранимок была только на прод-сервере.

Задачи

На второй месяц багфиксов этой системы меня начали посвящать в задачи по этой системе. Это, скорее, был набор боли, которая мешала компании работать, а не задачи. Выглядит это примерно так:

  • система тормозит и глючит

  • люди работают в экселе, им неудобно

  • очень медленно работает система

  • не хватает интеграции с экселем, когда сотрудники сделали несколько сверхсложных таблиц в экселе и надо это как-то поддержать в информационной системе

  • очень сильно тормозит

  • часто сваливается с ошибкой в критичной операции

  • сама операция длится 20-30 минут

  • операция вылетает по OutOfMemory на клиенте

  • некоторые окошки открываются 2-3 минуты

Впрочем, одна глобальная задача всё-таки была. Поставщики постоянно скидывали таблицы с перечнем товаров, которые нужно было обработать, поправить, согласовать и превратить в список закупок у этого поставщика. Вроде простая задача, но были пункты со звёздочкой: у крупного поставщика этот список в среднем был 60к строк, обрабатывать должен был целый отдел в 10-15 менеджеров под распределением координатора. Происходило это в экселе, в однопоточном режиме. Сначала работал координатор, потом по очереди передавали файлик по менеджерам. Естественно, это было долго, нужно было операцию распараллелить, чтобы драматически снизить время обработки.

Глобальной мечтой менеджмента был некий "disaster check". По русски - свод неких валидаторов, которые на этапе планирования предупреждают о грядущих эпик фейлах. Эх, не получилось по-русски.

Проблема была в том, что в информационной системе попросту не было данных планирования. Всё, что было, было по факту. Планирование всё было в экселе. Так как этот пункт без пункта №1 не делался, я его опущу.

Со мной в паре работал аналитик, совсем недавно нанятый компанией для нужд команды разработки. Парнишка грамотный, въедливый, трудолюбивый и иногда до раздражения упёртый. То, что надо для аналитика. Вот он и занимался документацией "as is", допросом бизнеса, фиксацией хотелок, проектированием процессов под эти хотелки и превращением всего этого добра в подробную структуру сущностей, припудривая распределением ролей - кто что может, а кто что не может.

План

Ну что-ж. Проблемы с тормозами я решу как-нибудь. Со временем. Там просто очень грязно код написан, нужно детально разобраться, сделать аккуратно, что-то закешировать и всё будет летать. Яж профессионал. Синьёр. Ага.

Кодовую базу тоже причешем. Не может это быть сложно. Ну большие портянки SQL, подумаешь! Понемногу будем вникать, упрощать, упорядочивать. Преобразования перекидывать на сторону шарпа, на стороне БД по возможности оставлять только хранение данных.

Прикрутим сбоку Entity Framework и будем постепенно переводить логику на него. Это снизит потребление памяти, а самое главное - сделает код сильно короче и понятнее. Бонусом отслеживание использования каждого поля, что очень пригодится в наведении порядка в структуре самой БД.

Фичу напишем, тоже ничего сложного. Стек понятный, задача понятна, чего ждать-то?

Я всё время помнил про NiH-синдром и про то, что я могу ошибочно воспринимать чужую кодовую базу как очень плохую и требующую переписывания. Поэтому старался такие мысли гнать подальше и сосредоточиться на плавных изменениях. Бизнес очень не любит переписывать заново то, что такими затратами ему далось ранее.

Вот с таким планом я к бизнесу и пришёл. Сказал, что ни в коем случае не будем всё переписывать, будем постепенно улучшать то, что у нас есть. Вы в надёжных руках профессионала. Ага.

Тормоза

К середине второго месяца синьёр-помидор догадался поставить брейкпоинт в метод инициализации окошка, где заполняются все поля. Очень уж тяжело было отлаживать это окошко - каждый раз после начала его открытия мозг давал команду ногам топать в сторону кофемашины. А столько кофе пить вредно. Надо что-то делать.

Так вот, пошаговый дебаг показал, что тормозит каждый запрос. Каждый. Сраный. Запрос. Вот выборка из вью вариантов для комбобокса, всего 6 тыс строк, содержащих айди и короткое имя. Сколько выполняется? 5500 мс. Вот ещё выборка из другой таблички, тоже немного, но уже 10 секунд. Ну и так далее.

Дальше как в тумане. Мозг категорически отказывался верить увиденному. Были испробованы другой коннектор и Entity Framework. Картина та же. Были перепробованы все менеджеры баз данных: бобр, жаба, грип, печка и девелопер. Все тормозили совершенно одинаково.

В результате была написана небольшая тулза-бенчмарк. Для чистоты эксперимента она выбирала напрямую из таблицы, а не вью (чтобы не учитывалась нагрузка на CPU). Табличку искал пожирнее в плане байт на строку, чтобы снизить влияние количества строк. И чтобы поменьше столбцов. Нашёл небольшую табличку, где были айди, небольшие XML-ки и ещё парочка столбцов. Идеально.

Запустил тест, отлимитив на 8000 записей. В результате 359 секунд. Ещё раз. Триста. Пятьдесят. Девять. Секунд. Я, конечно, всё понимаю, но я привык к несколько другим скоростям. Да, XML. Да, восемь тысяч. Но это не over 9000! По моим представлениям о прекрасном, такой объём нельзя грузить в конструкторе окошка, потому что сходу получаешь тормоза гуя на пару секунд. На пару секунд, хех. Я правда это сказал?

При пересчёте пэйлоада таблицы получилась скорость в 21 килобайт в секунду. Чуть быстрее модема конца прошлого века. Пэйлоад я считал исходя из UTF-16, то есть два байта на символ, что не обязательно и было в реальности. Собственно, если это был UTF-8, то результат ещё хуже, потому что XML чуть менее, чем полностью, состоял из латинских символов.

Были проверены разные варианты: из офиса по вай-фай, из офиса по проводу, из дома по проводу, из дома коллеги по проводу, из другой страны по проводу, из другой страны по вайфаю. Даже через мобильный интернет пробовали. Потом всё то же самое с тестовым сервером. Были испробованы разные настройки VPN, разные MTU. Результаты примерно одинаковые. Примерно. Но была интересная корреляция. Чем дальше хост, тем толще партизаны тормоза. Это оказалось важно, и в дальнейшем именовалось "пингозависимостью фетча".

По результатам тестирования был создан подробный тикет технарям, с результатами замеров и с приложенным бенчмарк-тулом. Также приложил тесты скачивания файлов в разные периоды рабочего времени. Большой файл и пачка маленьких - это разные тесты. У файлов была определённо зависимость от времени суток, в рабочее время оно тормозило сильней. Возможно, это как-то связано.

Спойлер: через год тикет закроют по "Won't fix". Но я тогда этого не знал, я думал, что починят. Не может же сама база данных так тормозить, дело наверняка где-то в сети. Ещё один спойлер: может.

Осознание

Через два месяца мне прямо сказали, что скорость никто не починит, ребятам некогда этим заниматься, они по уши в рутине и тушении пожаров. И вот тут накрыло осознание, что при таких вводных быструю систему не построить. Да, можно распараллелить операции, можно что-то закешировать, что-то оптимизировать. Но у этих методов есть свой предел. Если нужные данные грузятся 10 секунд, то за две секунды ты их никак не загрузишь.

Тормозит сам протокол передачи данных. И с этим ничего не поделать. Будь ты самый помидористый синьёр, самый прокаченный оптимизатор, умеющий вслепую писать красно-чёрные деревья, ты сделаешь ровно ничего.

А можно ли перенести БД поближе к офису? Да, у меня сразу возник такой вопрос. Оказалось, нельзя. Помните такую юмористическую передачу "маски-шоу"? Хоть передача давно была закрыта, руководство почему-то её боялось и ставило причиной категорического отказа от переноса БД в офис.

Копаться в настройках корпоративной сети и, тем более, базы данных мне никто не позволит. Что логично. Да я и не особо хотел, что не менее логично. Яж программист, оно мне надо?

Ну вот и всё. Приплыли. Что будем делать?

Будем писать свой ORM на три звена.

Этот отличный план, надёжный, как швейцарские часы, был выработан далеко не сразу. Сначала была проверка гипотезы. Нет, сначала был консилиум и долгие совещания. На них мы решили гипотезу проверить.

Для проверки мы арендовали в том же облаке яндекса, в той же подсети тестовый VPS с убунтой на борту. С неё я запускал тестовый запрос к БД на те же тестовые 8000 строк. Получалось очень быстро - гораздо быстрее, чем через VPN. Затем был выбор протокола передачи с сервера на клиент. Я хотел бинарный протокол, чтобы как можно быстрее передавались большие объёмы. В итоге взял простой советский протобуф третьего поколения.

Это тоже было отдельное исследование, по которому меня заставили писать целую статью в конфлюенсе. Там были сравнения скоростей в разных направлениях, от клиента к серверу, от сервера к БД, потом всё вместе, потом отдельно задержки от подачи запроса к первой полученной строке, потом к передаче целиком. Все тесты в двух вариантах - синхронная передача и асинхронная.

Синхронная передача выигрывала в одиночных запросах и во времени загрузки целиком (чуть-чуть). Асинхронная разгромно выигрывала в задержке получения первой строки. Но была сложнее. В итоге оставили обе.

И да. Тесты показали скорость загрузки тестовых 8000 строк ровно в 3 секунды. От начала запроса из офиса до получения последней строки в том же офисе. Ровно в 120 раз быстрее, чем прямой запрос, что я и вывел в заголовок статьи.

Был ещё вопрос, который у большинства читателей вертится на языке.

Почему не веб?

Краткий ответ: потому что эксель.

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

А ещё никто не умел писать веб. Я когда-то этим занимался, но это было давно и неправда. То, что я знал, на тот момент было дико устаревшим. Из фронтенда мы знали только винформс. Ну как "мы"... Я и ещё полтора программиста. Но это было заметно больше, чем знающих веб. Я ещё умею в WPF и хотел именно его, но остановились таки на винформс+девэкспресс, потому что если что, то хоть кто-то может это понять. Это и стало решающим фактором.

Забегая вперёд скажу, что никто с проектом мне не помогал, я писал его один. Но я до последнего надеялся, что остальные ребята будут со мной работать, ибо в одно лицо такой объём не вытащить. Зря надеялся. Ребята находились в состоянии перманентного аврала, разгребая несрастушечки в данных все полтора года, проведённых мной в этой компании.

Там действительно было что разгребать. Предыдущей командой был написан механизм синхронизации данных между 1С и ораклом. Оракл сам, своими хранимками подбирал выброшенные 1Ской джейсончики и распихивал по своим таблицам. Работало это крайне через ж... ненадёжно, почему-то какие-то джейсончики он пропускал, какие-то вносил дважды. Привереда какая.

И плюс ещё, незадолго до появления меня в компании там произошло стихийное бедствие. Стихийное бедствие было в виде апгрейда 1С до 8.3. Эска была затюнингованная вусмерть и после апгрейда тюнинг отвалился. Дальше был постоянный бег трусцой за данными. Сверки чинились медленнее, чем появлялись новые данные. Если на момент моего прихода отставание по сверкам было два месяца, то к моему уходу - полгода. Я могу что-то здесь путать, ибо с 1С я не работал никогда и как оно там происходит не знаю. Всё по памяти и с чужих слов.

Согласования.

Я просто написал "выбрали винформс". На самом деле не просто. Мы совещались, долго спорили, я писал кросс-демо, где было 4 варианта: винформс и WPF, с каждым вариантом был grid и spreadsheet. Словами я не смог убедить непосредственного руководителя, что спредшит не подходит ну совсем никак. Зачем писал вариант с WPF, когда всё упёрлось в скиллы разрабов? Да кто его знает.

Писал предполагаемую структуру, обязанности всех троих: БД, сервера и клиента. Ещё что-то писал. Ах, да, расписывал структуру механизма историзации. Мы только на этапе проектирования историзации застряли на месяц, хотя по факту эта историзация проекту как пятая нога собаке. Или полярному лису, если аналогия с Пелевиным не нравится.

Мы с руководителем спорили аж до хрипоты по каждой мелочи. Вадим, ну ты, блядь, серьёзно? Ты сам брал на работу профи с огромным стажем разработки. Зачем ты споришь с ним по каждому полю базы данных?

Позже я пришёл к другой тактике. Я уже знал, что любой даже самый мелкий вопрос вызовет непременное и непреклонное своё мнение со стороны Вадима. Я просто молча делал, как считал нужным, а спорили мы уже потом, по факту. И у меня был аргумент "ну я уже так сделал". Это работало гораздо эффективнее, чем остальные аргументы, потому что Вадим хочет премию.

Тем временем наступил март 2023. Работать мне оставалось девять месяцев. А мы только составили план дальнейшей работы.

План Б.

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

А дальше менеджмент видит, насколько она удобная, быстрая и экономит время сотрудникам. И мы потихоньку перекидываем функционал старой системы в новую. В этом был наш план, да. Просто сделать хороший продукт для внутреннего потребления.

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

Не сказать, что я ничего за это время не сделал. Я делал. Я потихоньку пилил ORM.

Зачем ORM?

Мне хотелось привести данные в порядок. Мне хотелось избавиться от бесконечных портянок в клиентском коде, которые занимаются тривиальными вещами, вроде получения данных и их распихиванием по полям объектов. Мне хотелось по "Find usages" находить все места, где используется это поле таблицы.

Но готовых ORM под трёхзвенку просто не существует. Существующие работают напрямую с базой данных, а мне нужна смена протокола.

К марту меня настигло осознание, что я буду это делать один. Поэтому срезаем углы где только можно. Мы уже поняли, что без трёхзвенки не будет смены протокола, а без смены протокола - вменяемой скорости. Для ускорения разработки нужно, чтобы запросы можно было отправлять прямо из клиента. То есть как бы двухзвенка, но "с бенефитами". Просто потому, что истинную трёхзвенку очень дорого делать. Нужно под каждый чих составлять симметричные поинты на клиенте и сервере, что мне не очень на руку. Но очень хочется местами ограничивать поля для клиента, например ему совсем не надо знать хэши паролей других пользователей.

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

Мы решили пойти по пути model-first. Не сказать бы, что у нас был широкий выбор при таких вводных данных. Вадим хотел обязательно картинки. Ну вот эти ER-диаграммы со стрелочками от таблицы к таблице. Оно без model-first не делается.

Следующий шаг - выбор инструмента, на который можно скинуть всю работу с моделью данных. Требования у меня были простые:

  • оно умеет работать с ораклом

  • оно умеет сохранять дополнительную информацию о полях и таблицах

  • его формат можно прочитать, чтобы потом нагенерить код по модели

  • при открытии таблицы видно комментарии к столбцам и их легко менять

Требования Вадима:

  • оно умеет рисовать ER-диаграммы

  • ОБЯЗАТЕЛЬНО чтобы стрелочки шли от поля с FK к ключу. Никак иначе. Просто от таблицы к таблице не пойдёт.

Я нашёл такой инструмент. Он платный. Но удовлетворяет всем требованиям и мы его купили, потому что в бесплатном варианте он не работает с моделью. Его я рекламировать не буду. Позже я создал свой собственный database management tool специально для таких случаев. Его я рекламировать буду, но не сейчас. Потом.

Мне очень нравится подход Entity Framework. Вот эта лаконичность его запросов, вот эта вся гибкость и join-on-demand. Короче, когда тебе нужен джоин, пишешь .Include(x => x.Field), когда не нужен - не пишешь. Плюсом ещё знакомый всем синтаксис. Если разраб шарпист, то почти наверняка знает, как запрашивать данные через EF.

Итого, требования для ORM:

  • Include, как в EF

  • поддержка Where, в том числе в джойненных таблицах

  • поддержка OrderBy и OrderByDesc

  • поддержка Take/Skip

  • поддержка OR, AND, NOT в условиях WHERE

  • маппинг в объекты

Начинаем.

Resurrection.

Для написания этой статьи я накатил оракл линукс 8 на виртуалку. А на оракл линукс поставил Oracle 21c XE. Не делайте так. Я потратил кучу нервов и времени, чтобы перезапустить его после перезагрузки машины. Возьмите лучше обычный докер-файл. И жрёт эта зараза 7 гигабайт памяти абсолютно пустая.

Но мне было интересно воссоздать максимально приближенные условия к тем, что были. Заодно понять, это действительно в протоколе проблема или у меня лыжи не едут.

После установки я накатил тестовые данные из одного репозитория. У меня так и не получилось вкатить большие таблички из csv файлов и мне даже не помогла гопота. Очень уж всё замудрено. Тем не менее, мелкие таблички всё-таки вносились и я взял схему sales_history:


Вадим, если ты это читаешь, смотри: тут стрелочки идут от поля внешнего ключа к полю первичного ключа. И никак иначе! Видишь? Всё для тебя.

Я отвлёкся. Берём табличку Products из нашей тестовой базы и запускаем фетч:

Здесь 288 рядов, которые зафетчились за 9 мс. Ничего удивительного, оракл запущен на локальной машине. Теперь надо проверить, будет ли эта версия тормозить. Нужен пинг как минимум 70мс, как было на работе. Как?

Совершенно случайно у меня есть VPS где-то в Манчестере, а на нём совершенно случайно есть WireGuard. Допустим. Можно было бы вкатить туда оракл, но я больше не хочу идти на такие жертвы. Да и памяти там не хватит. Вот что я придумал. Я подключаю рабочую машину к вайргарду, оставляя там подсеть 10.10.0.0/16. Подключаю виртуалку с оракл линуксом к той же подсети и вуаля! Я теперь могу обращаться к своей виртуалке по адресу 10.10.1.34 через Манчестер! Пинг до неё аж 230! Хорошо!

Давайте попробуем зафетчить ту же самую табличку, но уже через впн.

4,4 секунды! О как! Похоже, я перестарался с замедлением канала. Что стало видно сейчас - так это то, что данные поступают рывками. Это не тормоза OrmFactory, она в норме показывает плавный скролл на 60fps. А тут видно, что прямо слайд-шоу, как крайзис на встройке. Что любопытно, даже на глаз видно, что интервал "подёргиваний" примерно равен четверти секунды, то есть те самые 230мс пинга за небольшим плюсом на передачу очередного пакета.

Чтож. У нас получилось скукожить пропускную способность примерно в 400-500 раз. Теперь нужно раскукожить её обратно, не выходя за пределы нашего тормозного канала.

В 120 раз не ускорится, так как нашей табличке нужно будет побывать в Англии. За 40 мс не получится туда-обратно, законы физики запрещают. Должно получиться 240-250. Впрочем, увеличив табличку мы можем рисовать практически любые цифры прироста скорости.

Я сейчас пишу статью в 2025 по мотивам 2023 года. И буду писать, как будто OrmFactory уже существует и с его помощью я делаю ORM для этой компании. Прошу отнестись с пониманием к таким временным парадоксам.

Итак, это было вступление. А сейчас начинаем самое интересное - написание кода.

Технологии

Раз мы используем protobuf, то сделаем общение между клиентом и сервером по gRPC. Идея такая: клиент парсит дерево выражения запроса, сериализирует его в иерархичный XML, отправляет на сервер через gRPC, сервер составляет SQL запрос, получает данные и отправляет асинхронный поток в ответ клиенту опять же через gRPC. Вроде просто.

Сервер сделаем на .NET 8, клиент тоже будет .NET 8, а в качестве интерфейса будет Avalonia UI. Да, в оригинале клиент был на .NET Framework 4.7.2 и winforms, но нам бы лучше что-то кроссплатформенное.

Сервер в последствии выложим прямо на Oracle Linux и запустим сервисом. Докер не будем ставить, оно того не стоит. Можем собрать билд в виде Self-Contained, чтобы не ставить дотнет рантайм, или вообще через Native AOT. Рефлекшена на сервере всё равно не будет. Но это не точно.

Обратный поток - передача сущностей из клиента в БД будет умышленно отсутствовать в сгенерированном коде. Каждый инсерт и апдейт будет написан ручками на сервере и клиенте по мере необходимости. ORM будет поддерживать изменение объектов на сервере, но не на клиенте. Почему? Потому что изменение объектов - это бизнес-логика. Она должна быть под управлением сервера, а не клиента.

Создаём проекты для клиента и сервера, назвав их неожиданно Client и Server. Также я создам папку в корне проекта для модели. Назову Schema. В OrmFactory создаю новый проект, добавляю туда коннекшен к БД, импортирую нужную мне схему. Сохраняю как model в папке Schema. Начальная структура готова.

Генератор

Начнём с генерации сущностей. А вокруг сущностей уже будем строить механизмы их передачи.

Можно было бы использовать C# для написания генератора и использовать его как command line generator, но в этом есть неудобство. Нужно будет при каждом изменении пересобирать его. А изменять будем часто и хаотично. Конечно, для отладки есть ещё XML generator, который выдаст нам xml-модель (она отличается от структуры файла проекта) и позволит отлаживать наш генератор на статичном файле, но всё равно удобнее будет править и запускать скрипт, не вылезая из менеджера базы данных.

И так, заводим себе домашнюю змею с официального сайта. Будем питонить на программировании. В качестве отправной точки возьмём генератор для EF. Он будет опцией при добавлении нового генератора в проект. Сохраняем рядом с моделью. В опциях генератора сразу включаем конвертацию имён в CamelCase, оракловский стиль чуждо смотрится в коде шарпа. Теперь можно его править и пробовать запускать.

Так как образец генератора сделан под типы MySql, первым делом перелопачиваем конвертацию типов. У оракла нет нативного инта и все айдишники запиханы в NUMBER, в том числе в нашем примере. В одной таблице (Products) айдишник ограничен NUMBER(6,0), но в остальных это просто NUMBER. Ну да пусть. Будем всё намберное, что заканчивается на ID рассматривать как Int32. В остальных случаях рассмотрим precision и scale, после чего запихнём туда, куда влезает. Для этого придётся имя колонки пробросить в наш метод:

def resolve_type(db_type: str, column_name: str) -> str:
    db_type = db_type.lower().strip()

    if db_type.startswith("number"):    
        if column_name.endswith("ID"):
            return "int"
    
        precision = None
        scale = None

        if "(" in db_type and ")" in db_type:
            args = db_type[db_type.find("(") + 1 : db_type.find(")")]
            parts = [p.strip() for p in args.split(",")]
            if len(parts) >= 1 and parts[0].isdigit():
                precision = int(parts[0])
            if len(parts) == 2 and parts[1].isdigit():
                scale = int(parts[1])
            elif len(parts) == 1:
                scale = 0  # default if only precision is given

        if scale is not None and scale > 0:
            return "decimal"
        if precision is not None:
            if precision <= 9:
                return "int"
            elif precision <= 18:
                return "long"
            else:
                return "decimal"
        return "decimal"

    if db_type.startswith("float") or db_type.startswith("binary_float"):
        return "float"
    if db_type.startswith("binary_double"):
        return "double"
    if any(db_type.startswith(t) for t in ["varchar2", "nvarchar2", "char", "nchar", "clob", "nclob"]):
        return "string"
    if db_type.startswith("timestamp"):
        return "DateTime"
    if db_type.startswith("date"):
        return "DateOnly"
    if any(db_type.startswith(t) for t in ["blob", "raw", "long raw"]):
        return "byte[]"

    raise ValueError(f"Unknown type: {db_type}")

В схеме есть странные таблички вроде DR$SUP_TEXT_IDX$C, и я не знаю, что это такое, поэтому просто отключил их в partial settings генератора. Иначе мне придётся поддерживать тип данных ROWID, который в этих табличках используется.

Убираю аннотацию EF, немного правлю генерацию сущностей, добавляю генерацию класса-репозитория. Я решил делать без контекста, просто статические репозитории. Кому надо - возьмите свой любимый DI или сделайте с объявлением контекста, как в EF. Можно даже как и там, сделать IDisposable, правда внутри диспозить нечего.

В итоге получается вот такая простая модель:

using System;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using Client;

namespace Client.Data;

public static class Tables
{
	/// <summary>
	///small dimension table
	/// </summary>
	public static ChannelTable Channels = new();
	public static CostTable Costs = new();

	...
}

/// <summary>
///small dimension table
/// </summary>
public partial class Channel
{
	/// <summary>
	///primary key column
	/// </summary>
	public int ChannelId { get; set; }
	/// <summary>
	///e.g. telesales, internet, catalog
	/// </summary>
	public string ChannelDesc { get; set; }
	/// <summary>
	///e.g. direct, indirect
	/// </summary>
	public string ChannelClass { get; set; }
	public int ChannelClassId { get; set; }
	public string ChannelTotal { get; set; }
	public int ChannelTotalId { get; set; }
}

public partial class ChannelTable
{
}

public partial class Cost
{
	public int ProdId { get; set; }
	public DateOnly TimeId { get; set; }
	public int PromoId { get; set; }
	public int ChannelId { get; set; }
	public decimal UnitCost { get; set; }
	public decimal UnitPrice { get; set; }
	public Channel Channel { get; set; }
	public Product Prod { get; set; }
	public Promotion Promo { get; set; }
	public Time Time { get; set; }
}

...

Комментарии тупые и не везде. Это вообще-то была важная часть нашей парадигмы, поэтому даже были в требованиях к менеджеру БД. Комментируются поля в БД и затем попадают через генератор в код и видны в интеллисенс, что добавляет консистентности к схеме данных. Ладно, когда-нибудь перепишу на нормальные (нет).

Теперь нужно сгенерировать proto-файл. Очень удачно, что у нас и клиент и сервер на одной версии .NET, мы можем сделать общий контракт. Создаём в корне папку Shared, в ней проект GrpcContracts. В него подключаем библиотеки Grpc и Protobuf, создаём файл Generated.proto. У меня все генерированные файлы называются Generated. Это я так свою оригинальность выражаю. Зато вопросов не должно возникнуть, можно ли это править. Не должно же, правда?

Все протофайлы нужно специальным образом подключать к проекту в файле .csproj:

<ItemGroup>
  <Protobuf Include="Generated.proto" GrpcServices="Both" />
</ItemGroup>

GrpcServices="Both" значит, что будут сгенерированы и серверные, и клиентские интерфейсы.

Генерируем протофайлы по модели в том же генераторе. Я в генераторе конвертирую базаданновый тип в шарповый и дальше из него делаю протобуфный. Так проще оказалось.

syntax = "proto3";
option csharp_namespace = "GrpcContracts";
import "Common.proto";
import "google/protobuf/timestamp.proto";
package orm;

service Orm {
	rpc SelectChannel (SelectRequest) returns (ChannelReply);
	rpc SelectChannelStream (SelectRequest) returns (stream ChannelProto);
	rpc SelectCost (SelectRequest) returns (CostReply);
	rpc SelectCostStream (SelectRequest) returns (stream CostProto);
}

message SelectChannelReply {
	repeated ChannelProto Objects = 1;
	int32 ErrorCode = 2;
	string ErrorMessge = 3;
}

message ChannelProto {
	int32 ChannelId = 1;
	string ChannelDesc = 2;
	string ChannelClass = 3;
	int32 ChannelClassId = 4;
	string ChannelTotal = 5;
	int32 ChannelTotalId = 6;
}

message SelectCostReply {
	repeated CostProto Objects = 1;
	int32 ErrorCode = 2;
	string ErrorMessge = 3;
}

message CostProto {
	int32 ProdId = 1;
	DateProto TimeId = 2;
	int32 PromoId = 3;
	int32 ChannelId = 4;
	ProtoDecimal UnitCost = 5;
	ProtoDecimal UnitPrice = 6;
	optional ChannelProto Channel = 7;
	optional ProductProto Prod = 8;
	optional PromotionProto Promo = 9;
	optional TimeProto Time = 10;
}

То есть да, мы генерируем proto файлы, по которым кодогенератор генерирует файлы .generated.cs для проекта, по которым компилятор собирает IL, который затем компилируется в JIT.

Для поддержки decimal и DateOnly я сделал свои структуры и вынес их в Common.proto:

syntax = "proto3";

message DecimalProto {
    sint32 v1 = 1;
    sint32 v2 = 2;
    sint32 v3 = 3;
    sint32 v4 = 4;
}

message DateProto {
  uint32 year = 1;
  uint32 month = 2;
  uint32 day = 3;
}

Сюда же буду добавлять простенькие структуры, не имеющей чёткой привязки к конкретному процессу.

Теперь нам надо сделать XML запрос на выборку сущностей от клиента к серверу, используя LINQ. Соответственно, нужно унаследовать класс-репозиторий от базового класса, а в нём реализовать поддержку IQueryable.

Но начнём мы с простого: с формата XML файла. Так как это общий протокол обмена между клиентом и сервером, я запихнул работу с этим форматом в GrpcContracts. Не буду ничего выдумывать, возьму стандартный сериализатор дотнета и расставлю аннотации, чтобы оно там само из экземпляра класса сделало XML. Сам класс выглядит простенько:

public class RequestExpression
{
	[XmlAttribute, DefaultValue(OrderBy.Ascending)]
	public OrderBy OrderBy = OrderBy.Ascending;
	[XmlAttribute, DefaultValue("")]
	public string OrderByField = "";

	public Limit Limit = new Limit();
	public bool ShouldSerializeLimit() => Limit.Skip > 0 || Limit.Take > 0;
	[XmlElement]
	public List<string> Include = new List<string>();
	public bool ShouldSerializeInclude() => Include.Any();
	[DefaultValue(null)]
	public WhereCondition Condition;
}

public enum OrderBy
{
	Ascending,
	Descending
}

public enum WhereOperator
{
	//unary
	Parameter,
	Value,
	Not,

	//binary
	And,
	Or,
	Equal,
	GreaterThan,
	GreaterThanOrEqual,
	LessThan,
	LessThanOrEqual,
	Contains,
}

public class Limit
{
	[XmlAttribute, DefaultValue(0)]
	public int Skip;
	[XmlAttribute, DefaultValue(0)]
	public int Take;
}

public class WhereCondition
{
	public WhereCondition() { }
	public WhereCondition(WhereCondition left, WhereOperator op, WhereCondition right)
	{
		Left = left;
		Operator = op;
		Right = right;
	}

	[XmlAttribute]
	public WhereOperator Operator;
	[XmlAttribute, DefaultValue("")]
	public string Value = "";
	[DefaultValue(null)]
	public WhereCondition Left;
	[DefaultValue(null)]
	public WhereCondition Right;
}

Здесь надо обратить внимание на рекурсивный WhereCondition. Туда будет складываться дерево условий, которые мы будем писать в логике клиента. Так как есть поддержка унарных операций, то или левый или правый операнд могут быть null.

Конечно, можно было бы и под эту структуру сделать proto-файл, но мне показалось, что через XML проще. Да и смысла экономить байтики на структуре запроса совершенно не вижу.

Теперь базовый репозиторий.

public abstract class TableBase<TEntity> : IQueryable<TEntity>
{
	public Expression Expression => Expression.Constant(this);

	public Type ElementType => typeof(TEntity);

	public IQueryProvider Provider => new LinqProvider<TEntity>(this);

	public IEnumerator<TEntity> GetEnumerator()
	{
		return new LinqProvider<TEntity>(this).GetEnumerator();
	}

	IEnumerator IEnumerable.GetEnumerator()
	{
		return GetEnumerator();
	}
}

Нам надо будет его вызывать цепочкой модификаторов вроде:

Products.Where(...).OrderBy().Take().Skip().Include(...) и получать на выходе IEnumerable.

На самом деле никакого IEnumerable не будет. На каждом этапе будет новый IQueryable, а когда будет вызов цепочки, вот тогда и произойдёт отправка запроса и конвертация ответа в IEnumerable. Асинхронкой займёмся потом.

Проблема в том, что наш базовый тип будет только в самом начале цепочки, после первого Where это превратится в IQueryable и никакого Include там в методах не будет. Я посмотрел в коде EF Core, как сделано там. Там тупо повесили extension method на IQueryable. Ну и мы не будем париться.

public static IQueryable<T> Include<T>(this IQueryable<T> queryable, Expression<Func<T, object>> expr)
{
	var member = expr.Body as MemberExpression;
	var name = member.Member.Name;

	var provider = queryable as LinqProvider<T>;
	if (provider == null)
	{
		var table = queryable as TableBase<T>;
		if (table == null) throw new Exception("Can't include: repo must be LinqProvider or TableBase");
		provider = new LinqProvider<T>(table);
	}

	provider = provider.Clone();
	provider.IncludeFields.Add(name);
	return provider;
}

Теперь мы можем ставить Include в любом месте нашей цепочки. Делаем тестовый запрос:

var list = Tables.Costs
	.Include(c => c.Promo)
	.Where(c => c.ProdId == 0 && c.UnitCost > 10.0m)
	.OrderBy(c => c.ChannelId)
	.Include(c => c.Prod)
	.Take(10)
	.ToList();

Для того, чтобы получить заветный XML, нам осталось добавить ExpressionSerializer, который вызывается в провайдере, а тот уже должен распарсить дерево выражения и превратить в наши сериализуемые объекты.

public class ExpressionSerializer
{
	private static XmlSerializer serializer;

	private readonly List<Expression> expressions;
	private readonly List<string> include;

	public ExpressionSerializer(List<Expression> expressions, List<string> include)
	{
		this.expressions = expressions;
		this.include = include;
	}

	public static RequestExpression Deserialize(string request)
	{
		if (serializer == null) serializer = new XmlSerializer(typeof(RequestExpression));
		using var reader = new StringReader(request);
		return serializer.Deserialize(reader) as RequestExpression;
	}

	public bool IsDefaultNull { get; internal set; }

	public string GetXml()
	{
		if (serializer == null) serializer = new XmlSerializer(typeof(RequestExpression));

		var exp = GetRequestExpression();

		var settings = new XmlWriterSettings();
		settings.IndentChars = "\t";
		settings.Indent = true;
		settings.NewLineChars = "\n";
		settings.Encoding = Encoding.UTF8;

		using (var writer = new StringWriter())
		using (XmlWriter xw = XmlWriter.Create(writer, settings))
		{
			serializer.Serialize(xw, exp);
			return writer.ToString();
		}
	}

	public RequestExpression GetRequestExpression()
	{
		var exp = new RequestExpression();
		foreach (var e in expressions)
		{
			AddExpression(exp, e);
		}
		exp.Include.AddRange(include);
		return exp;
	}

	private void AddExpression(RequestExpression re, Expression e)
	{
		var method = e as MethodCallExpression;
		var name = method.Method.Name;
		if (name == "Where")
		{
			AddWhereCondition(re, method);
			return;
		}
		if (name == "OrderBy")
		{
			AddOrderBy(re, method);
			re.OrderBy = OrderBy.Ascending;
			return;
		}
		if (name == "OrderByDescending")
		{
			AddOrderBy(re, method);
			re.OrderBy = OrderBy.Descending;
			return;
		}

		if (name == "Skip")
		{
			re.Limit.Skip = GetIntArgument(method);
			return;
		}

		if (name == "Take")
		{
			re.Limit.Take = GetIntArgument(method);
			return;
		}
		throw new NotImplementedException("method " + name);

	}

	private int GetIntArgument(MethodCallExpression method)
	{
		var args = method.Arguments;
		if (!args.Any()) throw new Exception("no args");
		var arg = method.Arguments.Last();

		if (arg is ConstantExpression ce)
		{
			return (int)ce.Value;
		}
		if (arg is MemberExpression ex)
		{
			var objectMember = Expression.Convert(ex, typeof(object));
			var getterLambda = Expression.Lambda<Func<object>>(objectMember);
			var getter = getterLambda.Compile();
			var value = getter();
			return (int)value;
		}

		throw new Exception("must be member expression");
	}

	private void AddOrderBy(RequestExpression re, MethodCallExpression method)
	{
		var args = method.Arguments;

		if (!args.Any()) return;

		var arg = args.Last();

		if (!(arg is UnaryExpression lambda)) return;
		if (arg.NodeType != ExpressionType.Quote) return;

		if (lambda.Operand is not LambdaExpression predicate) return;
		if (predicate.Body is not MemberExpression ex) return;
		if (ex.Expression.NodeType != ExpressionType.Parameter) return;
		re.OrderByField = ex.Member.Name;
	}

	private void AddWhereCondition(RequestExpression re, MethodCallExpression method)
	{
		var args = method.Arguments;

		if (!args.Any()) return;

		var arg = args.Last();

		if (arg is not UnaryExpression lambda) return;
		if (arg.NodeType is not ExpressionType.Quote) return;
		if (lambda.Operand is not LambdaExpression lambdaExpression) return;

		var wc = new LambdaExpressionParser(lambdaExpression).GetWhereCondition();

		if (re.Condition == null)
		{
			re.Condition = wc;
			return;
		}

		re.Condition = new WhereCondition(re.Condition, WhereOperator.And, wc);
	}
}

Достаточно простая и расширяемая конструкция. Можно добавлять поддержку шарпового Contains, например, превратив его в оператор IN() на той стороне. Или какого-нибудь унарника вроде DateDime.Date. Но пока что нам не надо. Такими вещами можно заниматься бесконечно и всё равно не поддержать весь синтаксис.

Теперь парсер дерева выражений:

public class LambdaExpressionParser
{
	private readonly Expression expression;
	private string parameterName;

	public LambdaExpressionParser(LambdaExpression lambda)
	{
		expression = lambda.Body;
		parameterName = lambda.Parameters[0].Name;
	}

	public WhereCondition GetWhereCondition()
	{
		return GetWhereCondition(expression);
	}

	private WhereCondition GetWhereCondition(Expression expr)
	{
		if (expr is BinaryExpression bin)
		{
			var left = GetWhereCondition(bin.Left);
			var right = GetWhereCondition(bin.Right);

			if (bin.NodeType == ExpressionType.AndAlso) return new WhereCondition(left, WhereOperator.And, right);
			if (bin.NodeType == ExpressionType.OrElse) return new WhereCondition(left, WhereOperator.Or, right);
			if (bin.NodeType == ExpressionType.Equal) return new WhereCondition(left, WhereOperator.Equal, right);
			if (bin.NodeType == ExpressionType.GreaterThan)
				return new WhereCondition(left, WhereOperator.GreaterThan, right);
			if (bin.NodeType == ExpressionType.GreaterThanOrEqual)
				return new WhereCondition(left, WhereOperator.GreaterThanOrEqual, right);
			if (bin.NodeType == ExpressionType.LessThan)
				return new WhereCondition(left, WhereOperator.LessThan, right);
			if (bin.NodeType == ExpressionType.LessThanOrEqual)
				return new WhereCondition(left, WhereOperator.LessThanOrEqual, right);

			throw new NotImplementedException(bin.NodeType.ToString());
		}

		if (expr is UnaryExpression un)
		{
			if (un.NodeType == ExpressionType.Not)
			{
				return new WhereCondition(GetWhereCondition(un.Operand), WhereOperator.Not, null);
			}

			if (un.NodeType == ExpressionType.Convert)
				return GetWhereCondition(un.Operand);

			throw new NotImplementedException(un.NodeType.ToString());
		}

		if (expr is MemberExpression ex)
		{
			var memberNames = GetMemberChain(ex);

			if (memberNames.First() == parameterName)
			{
				memberNames.RemoveAt(0);
				var par = new WhereCondition()
				{
					Operator = WhereOperator.Parameter,
					Value = string.Join(".", memberNames)
				};

				var propInfo = ex.Member as PropertyInfo;

				if (propInfo?.PropertyType == typeof(bool))
				{
					par = new WhereCondition
					{
						Left = par,
						Right = new WhereCondition
						{
							Operator = WhereOperator.Value,
							Value = "1"
						},
						Operator = WhereOperator.Equal
					};
				}

				return par;
			}

			//https://stackoverflow.com/questions/2616638/access-the-value-of-a-member-expression
			var objectMember = Expression.Convert(ex, typeof(object));
			var getterLambda = Expression.Lambda<Func<object>>(objectMember);
			var getter = getterLambda.Compile();
			var value = getter();
			return new WhereCondition
			{
				Operator = WhereOperator.Value,
				Value = GetValue(value)
			};
		}

		if (expr is ConstantExpression c)
		{
			return new WhereCondition
			{
				Operator = WhereOperator.Value,
				Value = GetValue(c.Value)
			};
		}

		if (expr is MethodCallExpression call)
		{
			if (call.Method.Name == "Contains")
			{
				WhereCondition left;
				WhereCondition right;

				if (call.Arguments.Count == 1)
				{
					var exArg = call.Object;
					left = GetWhereCondition(exArg);
					right = GetWhereCondition(call.Arguments[0]);
					return new WhereCondition(left, WhereOperator.Contains, right);
				}

				if (call.Arguments.Count == 2)
				{
					left = GetWhereCondition(call.Arguments[0]);
					right = GetWhereCondition(call.Arguments[1]);
					return new WhereCondition(left, WhereOperator.Contains, right);
				}
			}

			throw new NotImplementedException(expr.ToString());
		}

		throw new NotImplementedException(expr.ToString());
	}

	private List<string> GetMemberChain(MemberExpression ex)
	{
		var list = new List<string>();
		if (ex.Expression is MemberExpression me) list = GetMemberChain(me);
		if (ex.Expression is ParameterExpression pe)
		{
			list.Add(pe.Name);
		}
		list.Add(ex.Member.Name);
		return list;
	}

	private string GetValue(object obj)
	{
		if (obj is string s) return "'" + s + "'";
		if (obj is int i) return i.ToString();
		if (obj is long l) return l.ToString();
		if (obj is Enum) return ((int)obj).ToString();
		if (obj is DateTime dt) return $"'{dt:G}'";
		if (obj is IEnumerable<int> en) return string.Join(", ", en);
		if (obj is decimal d) return d.ToString();
		if (obj is null) return "null";
		throw new NotImplementedException("Unsupported argument type " + obj.GetType());
	}
}

Теперь у нас всё готово и мы перехватываем выполнение нашего тестогово запроса в LinqProvider, чтобы посмотреть, какая XML у нас получилась:

<?xml version="1.0" encoding="utf-16"?>
<RequestExpression xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" OrderByField="ChannelId">
	<Limit Take="10" />
	<Include>Promo</Include>
	<Include>Prod</Include>
	<Condition Operator="And">
		<Left Operator="Equal">
			<Left Operator="Parameter" Value="ProdId" />
			<Right Operator="Value" Value="0" />
		</Left>
		<Right Operator="GreaterThan">
			<Left Operator="Parameter" Value="UnitCost" />
			<Right Operator="Value" Value="10,0" />
		</Right>
	</Condition>
</RequestExpression>

Вроде всё на месте. Переходим к серверу. Там предстоит сделать сущности, репозитории и обеспечить выполнение запроса.

План такой: мы принимаем этот XML, возвращаем ему обличие RequestExpression и трансформируем его в текстовый sql запрос. Потом учимся читать объекты из датаридера и только потом делаем linq на стороне сервера.

Делаем класс SqlRequest, который ни от кого пока не зависит, кроме нашего RequestExpression. Там будет только два интересных куска. Это рекурсивная обработка иерархии Where:

public string ParseWhere(WhereCondition condition)
{
	if (condition == null) return "";
	if (condition.Operator == WhereOperator.Value) return condition.Value;
	if (condition.Operator == WhereOperator.Not) return "NOT (" + ParseWhere(condition.Left) + ")";
	if (condition.Operator == WhereOperator.Or) return "(" + ParseWhere(condition.Left) + " OR " + ParseWhere(condition.Right) + ")";
	if (condition.Operator == WhereOperator.And) return ParseWhere(condition.Left) + " AND " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.Equal) return ParseWhere(condition.Left) + " = " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.GreaterThan) return ParseWhere(condition.Left) + " > " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.GreaterThanOrEqual) return ParseWhere(condition.Left) + " => " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.LessThan) return ParseWhere(condition.Left) + " < " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.LessThanOrEqual) return ParseWhere(condition.Left) + " =< " + ParseWhere(condition.Right);
	if (condition.Operator == WhereOperator.Parameter) return condition.Value;

	if (condition.Operator == WhereOperator.Contains)
	{
		return ParseWhere(condition.Right) + " IN(" + ParseWhere(condition.Left) + ")";
	}

	throw new NotImplementedException(condition.Operator.ToString());
}

И сборка запроса целиком:

public string GetTextRequest()
{
	var requestParts = new List<string>();
	requestParts.Add("SELECT " + GetFieldAliases());
	requestParts.Add("FROM " + table.SchemaDbName + "." + table.TableDbName + " " + TableAsName);
	if (WhereSql != "") requestParts.Add("WHERE " + WhereSql);
	if (OrderBySql != "") requestParts.Add("ORDER BY " + OrderBySql);
	if (Skip != 0) requestParts.Add("OFFSET " + Skip + " ROWS");
	if (Take != 0) requestParts.Add("FETCH NEXT " + Take + " ROWS ONLY");
	var request = string.Join("\n", requestParts);
	Console.WriteLine(request);
	return request;
}

Ничего сложного тут нет. Я сюда добавил зачатки трансляции .Contains(), возможно потом поддержим на клиенте. Наверное. Но это не точно.

Но прежде, чем мы получим текстовый запрос в СУБД, нам придётся сгенерировать сущности и репозитории. Потому что нам нужен будет список полей таблицы, которые будут выбираться из базы. Я не стану выбирать по *, потому что двойной джоин к одной и той же таблице гарантировано даст нам коллизию имён, а значит нам сразу надо сделать альяс для каждого поля. У нас должна быть гарантия наоборот - что никаких коллизий не будет.

public abstract class TableBase<T> : ISqlTable, ITable<T>
{
	public abstract string TableDbName { get; }
	public abstract string SchemaDbName { get; }

	public abstract Dictionary<string, string> PropertiesToFields { get; }
	public abstract string[] PrimaryKeyProperties { get; }

	protected SqlRequest CreateRequest() => new SqlRequest(this);

	public IEnumerable<T> GetEntities()
	{
		var sqlRequest = CreateRequest();
		return GetEntities(sqlRequest);
	}

	public IEnumerable<T> GetEntities(RequestExpression expression)
	{
		var request = GetRequest(expression);
		return GetEntities(request);
	}

	internal IEnumerable<T> GetEntities(string xmlRequest)
	{
		var expression = ExpressionSerializer.Deserialize(xmlRequest);
		var request = GetRequest(expression);
		return GetEntities(request);
	}

	public abstract IEnumerable<T> GetEntities(SqlRequest req);

	private SqlRequest GetRequest(RequestExpression expression)
	{
		var sqlRequest = CreateRequest();
		sqlRequest.SetWhere(expression.Condition);
		sqlRequest.SetLimit(expression.Limit);
		sqlRequest.SetOrderBy(expression.OrderByField, expression.OrderBy);
		return sqlRequest;
	}
}

public abstract class EntityBase
{
	public abstract void LoadFromReader(FieldReader fr, string tableName);
}

Подключаем Oracle.ManagedDataAccess.Core и создаём FieldReader. Эта такая портянка, которая имеет перегрузку Read под каждый тип поля. Нужна, чтобы мы могли в коде загрузки сущности писать просто fr.Read(ref field, "FIELD").

public class FieldReader
{
	private readonly OracleDataReader dr;

	public FieldReader(OracleDataReader dr)
	{
		this.dr = dr;
	}

	public bool Read()
	{
		return dr.Read();
	}

	public void Read(ref int o, string fieldName)
	{
		o = Convert.ToInt32(dr[fieldName]);
	}
}

Дальше я добавляю в сущности клиента конвертацию в прото-классы и из прото-классов. Для конвертации прото в сущность я использовал конструктор класса сущности, а обратно - метод GetProto(). Надо ещё заметить, что нужно написать конверторы для некоторых типов, не имеющих прямого соответствия в protobuf. На данный момент это decimal и DateOnly.

На этот момент тот я, который из 25-го года, уже дико устал от написания этой системы. Но осталось немного. Нужно сгенерировать сущности на сервере, там же репозитории и как-то заставить это всё заработать вместе. Эндпоинт отправки данных я пока напишу ручками для одной таблицы.

И так, я всё это собрал. Делаем простой грид, простую кнопку и простую вьюмодель:

public class MasterViewModel: NotifyPropertyChangedBase
{
	private string fetchedTime = "0";

	public string FetchedTime
	{
		get => fetchedTime;
		set => SetField(ref fetchedTime, value);
	}

	public ObservableCollection<Product> Products { get; set; }

	public MasterViewModel()
	{
		Products = new ObservableCollection<Product>();
	}

	public void LoadProducts()
	{
		var sw = Stopwatch.StartNew();
		foreach (var product in Tables.Products)
		{
			var elapsed = sw.ElapsedMilliseconds + "ms";
			Dispatcher.UIThread.Post(() =>
			{
				FetchedTime = elapsed;
				Products.Add(product);
			});
		}
		sw.Stop();
	}
}

По кнопке запускаем LoadProducts(). Запускаем сервер локально и нажимаем кнопку в клиенте:

Не знаю, что я там намудрил, я хотел, чтобы также рывками грузилось. Ну да ладно. Паблишим сервер под линукс, загоняем через ftp, далее chmod +x, далее, далее, пропустить. Запускаем сервер.

Переключаем клиента на канал с VPN. Ииииии

1,2 секунды. Нда. Но что есть, то есть. Правда второй запуск уже давал примерно 700 мс, что чуть лучше, но всё равно не дотягивает до 250.

Тем не менее, номинально задача выполнена. Работает быстрее. Всего лишь в 4 раза, но опять же, увеличивая объём данных можно добиться любой кратности прироста. Почти.

Общая Шина

Вадим грезил Общей Шиной. И терраформом. Я не очень хорошо понимал, что такое терраформ, и поэтому не очень сильно язвил по этому поводу.

Но что такое Общая Шина, я представлял прекрасно. Эта штука, по задумке Вадима, должна была полностью решить все проблемы с передачей данных между системами. Это такой message broker, только круче и универсальнее. Прямо как в том древнем лонгриде от Ашманова. А по моему опыту настолько абстрактные проекты ничего не решают, а только добавляют проблем. И чаще всего не воплощаются в жизнь. А если и воплощаются и даже как-то работают, то исключительно за счёт эффекта "каши из топора".

Впрочем, Вадим меня не напрягал разговорами об "Общей Шине". Всё равно он не рисковал давать мне её разработку, потому что хотел премию. Только рассказывал, что мне всё нужно будет в будущем пересылать через Общую Шину и надо к этому уже сейчас готовиться. Знать бы ещё, как к ней обращаться.

Он искал отдельного человека для её реализации. Точнее, бюджет на этого человека. Я же до конца совместной работы периодически спрашивал Вадима, нашёл ли он шиномонтажника. Не знаю, оценил ли он эту шутку или всерьёз думал, что мне эта шина чем-то бы помогла.

Для своего проекта я не хотел судьбу "Общей Шины", поэтому сосредотачивался на конкретных вещах, которые можно увидеть и пощупать. Чем конкретнее понимаешь, какой ты хочешь результат - тем вероятнее его добиться.

Чекпоинт

И так, 1 июня у нас был запланирован чекпоинт с демонстрацией достижений менеджменту. Была готова первая версия ORM. Пока без джойнов, я их прикручу чуть позже, но уже умеющая забирать данные. Отправка данных была сделана отдельно от ORM через gRPC.

Был сделан логин, система ролей пользователей. Роли были сразу сделаны иерархичными и включающими в себя другие роли. Пользователь мог иметь несколько ролей.

Был сделан набросок бизнес-логики для будущей задачи. Было несколько окошек: для внесения заказа, там работал импорт из экселя и отправка на сервер, для его просмотра и для редактирования параметров. Была сделана система переводов из статуса в статус (в черновике пока). Были пользователи, были вендоры, были окошки для внесения и редактирования того и другого. По-моему даже бренды были.

Была сделана клиент-серверная система трекинга данных. Это когда один человек вносит заказ в базу данных - а у всех остальных он появляется в списке. Понятно, что события отслеживались только те, которые проходили через сервер.

Да мы даже успели сделать редизайн!

Презентация выглядела великолепно. Файл экселя на 60к строк засасывался в клиента и обрабатывался за минуту и за считанные секунды отправлялся на сервер. Потом его можно было открыть и посмотреть. Аналитик с другой машины менял имена и добавлял новые проекты, менял им статус. Менеджмент смотрел, как у меня имена и статусы меняются сами и появляются сами новые проекты.

Переход по статусам, разграниченный по правам, запрет на редактирование в "отмороженных" статусах. Автоматическая фиксация юзера, создающего сущность. Даты создания и последнего изменения. Свободные комментарии в парадигме insert-only к каждой сущности. Это всё должно было стать стандартом новой системы.

Сам клиент запускался две секунды. И сразу со списком заказов в разных статусах. Цветастое. С иконочками. Кросивое. И любой документ открывался моментально. Здесь я на хитрость пошёл - я грузил данные асинхронно, поэтому первые данные появлялись действительно сразу, заполняя грид. И можно было чуть заболтать менеджмент, пока дока не прогрузится и задисейбленные кнопочки не заэнейблятся. Полная загрузка тяжёлого документа занимала секунд 15.

Красота! Булочка просто, а не демка!

Но менеджмент как будто это не интересовало. Он как-то сдержанно отнёсся к достижениям нашего велосипедостроения. И в конце выдал пару новых задач.

А мне оставалось работать шесть месяцев.

Две задачи

Первая задача - это автоматически определить, в какую нашу внутреннюю товарную категорию нужно определить каждую строчку товара. Напоминаю - файл не наш, а категории наши. И файл может содержать всё что угодно. Он может быть даже на английском языке с французскими вкраплениями. Ладно, про французский я преувеличил. Вот ЭТО, заполненное абы кем и абы как, надо каталогизировать. Народ думал в сторону ИИ. Но в нём ещё надо разобраться, найти хост и ещё обучить. И как-то интегрировать.

Вторая задача немного похожа на первую. Нужно было взять другой список из товаров, где абы как и абы кем написан состав. Опять на смеси русского и английского. Потом привести это безобразие в соответствии с законами о розничной продаже РФ. То есть стандартные материалы, отсортированные по убывающей, единый стандартный формат.

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

Первая задача заняла недели три. Я решил обе делать на новой платформе, чтобы протестировать её в бою. Документ нужно было загрузить из экселя в систему, потом запустить автоопределение категорий. Алгоритм пытался найти подходящую, если была неоднозначность - предлагал варианты, а конечный вариант оставлял пустым, для ручного заполнения. Получалась некая гарантия, что предположение не будет воспринято, как определение.

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

Я тестировал до получения приемлемого результата на файлах, которые аналитик взял у бизнеса в качестве примера. В итоге сделал к каждой категории список ключевых слов, на которые и агрился распознаватор. Чтобы не было слишком распухшее, сделал механизм синонимичности. Потом заинтерфейсил редактирование этого всего, добавил столбец с найденными ключевыми словами и отдал аналитику. Он уже допиливал ключевые слова, повышая процент распознавания. Процент распознавания, к слову, был от 80 до 95 процентов.

Вторая задача оказалась неожиданно сложнее.

Форматы исходных данных были совершенно разные: где-то через запятую, где-то через пробел и так далее. Особая боль - композитные материалы. Всё вместе давало чудовищную комбинаторную сложность. Пришлось помимо двух вспомогательных таблиц прибегнуть к чёрной магии последовательной обработки. В итоге было сдано с интерфейсом для дополнения этих таблиц, инструкцией пользователя и очень хорошими показателями разпознавания. Дальше пользователи повышали показатели распознавания сами. Как и в первой задаче, среди разпознанных строк процент корректных был 100%. Ещё плюс три недели.

В середине разработки этих фич увольняют одного разраба. Оптимизация.

В конце июля уходит наш руководитель. Нет, не Вадим. Вадим руководитель отдела разработки. А уходит Максим. Он руководитель айти отдела, и Вадима тоже. Макс вместе с Вадимом проводил собеседование со мной и брал меня на работу.

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

Вадим сказал: ты следующий. Вас трое осталось, но без тех двоих сразу всё рухнет. Так что готовься.

Вадим оказался неправ, но я тогда этого не знал. И работать стало несколько неспокойненько. Оставалось четыре месяца.

Апдейтер

Был сделан механизм автообновления с версионированием клиента. При подключении к серверу клиент запрашивал последнюю версию клиентского ПО, тот выдавал клиенту xml-файл с тем, что нужно скачать. Клиент запускал апдейтер с этим файлом, апдейтер качал бинари и перезапускал уже нового клиента. Со своей стороны сделал кнопку релиза, которая выгружала актуальную версию на сервер. Всё через gRPC.

Задача его была простая. Дать мне возможность часто релизиться, не делая голову ни себе, ни юзерам. И общаться с пользователями я не очень люблю, так что не искал повода покрасоваться в общем чате. Да и при отсутствии QA однокнопочный релиз - это не прихоть, это необходимость.

Старая ИС просто выкладывалась в шаре и каждому юзеру на рабочий стол выносился ярлык на батник, который при каждом запуске перезаписывает клиента на "нового" из шары. После выпуска новой версии необходимо было оповестить весь персонал о смене версии, чтобы перезагрузились.

Клиента старая команда упаковала с помощью fody в один файл весом 80мб, чтобы не заморачиваться со списком файлов. Шара работала очень хреново, особенно в рабочее время, поэтому клиент обычно запускался от двух до пяти минут. Расположение клиента должно было быть стандартное, иначе система не работала.

В моей версии апдейтера выскакивало окно с предложением обновиться. При старте клиента, если он обнаруживал новую версию, обновлялся принудительно, не спрашивая пользователя. Это корпоративный сегмент, детка. Тут нефиг в старой версии сидеть. Тут даже в предложении обновления была настойчивость и отсутствовал ChangeLog. Но в последствии он будет в обновлённом клиенте, уже по факту обновления. Чтобы не спрашивали, а сделал ли я какую-либо задачу.

Третья задача.

Третья задача оказалась совершенно мерзкой. Неприятной. Честный Знак, который не честный и не знак, обещал отрубить апи v2 для регистрации куар-кодов индивидуальных товаров. Нужно было переходить на v3, но у нас 1с-ники были загружены переходом на 8.3. Полтора года уже, ага. Конечно, то, что отрубят - это писями по воде виляно. Не мы одни такие раздолбаи. Но к дедлайну совершенно точно не успевали починить 1ску. Мне дали две недели и сертификат для входа в апи. Нужно было сделать отдельный синхронизатор, берущий поток чеков из БД и отправляющий в апи. Это я сейчас упрощённо, на практике там много нюансов, вроде множества позиций чека, каких-то кодов ОКВДБЛИАД, групп товаров и разрегистрирования куар обратно. Потому что возврат.

Вадим почему-то не вспомнил про премию, а когда я ему напомнил, почему-то проникся корпоративным духом и преисполнился сочувствием к девочкам, которым придётся отправлять каждый вечер это вручную. Теперь уже я отбрыкивался всеми рогами и копытами. Конечно, в премию я не верил, но где-то в глубине души теплилась надежда. Ну вы же понимаете? Но силы были не равны.

Работать оставалось три месяца.

Пришлось поставить крипто-про и ещё какую-то госмалварь на комп, чтобы дотнет скушал сертификат. Кстати, крипто-про платный и у меня был месяц триала. Я подключился к апи v3, собрал джейсончики, но отправлять было стрёмно. Да хрен его знает, где я навертел и как отнесётся ЧЗ к повторной отправке данных. Кассы-то ещё работают. Да и уверенности в корректности данных оракла у меня не было. Там же синхронизация, помните?

Проблема была в том, что сертификат был от прода Честного Знака, а от теста ни у кого не было. И для регистрации в лк нужны были учередительные документы. О чём я радостно сообщил менеджменту. Предложил на выбор: или давайте готовый сертификат на тест или пусть кто-нибудь возьмёт на себя ответственность и даст мне добро на тестирование в проде. Менеджмент ушёл думать (спойлер: и не вернулся).

В середине октября сертификат протух, а я вздохнул с облегчением. Теперь я мог бессовестно разводить руками лапками и говорить, что без сертификата я не могу даже подключиться.

На следующий день уволили Вадима.

Закат без рассвета

Через неделю или две менеджмент вышел на связь. На еженедельном созвоне присутствовали новые лица. Саша и ещё один парнишка из его команды. Как мне объяснили, руководство обратилось к старой команде разработки для помощи в критичное время.

На созвоне Саша представился, повторил несколько раз, что он тут временно и ну ни в коем случае не собирается возглавлять отдел разработки.

Но почему-то много внимания было уделено моей системе. Я рассказывал про причины появления такой системы, показал наглядно фетч курильщика и фетч здорового человека (за пару секунд зафетчил табличку побольше из MySql). Отправил в чат ссылки на тикет по тормозам и статью в конфе на исследования по скорости. Это были железобетонные аргументы, документированные в общей системе. Кстати, как вы думаете, кто заставил меня в своё время их написать? Правильно - Вадим.

Саша, будто меня не замечая, называл меня в третьем лице и говорил, что я развожу руководство на деньги. Он оперировал суммой в 6 млн, уже потраченных на проект. Не знаю, откуда он взял такую сумму. Говорил, что старая система хорошая и нет смысла тратиться на новую. Мне было неприятно.

Но с проблемой тормозов Саша нехотя согласился, после наглядной демонстрации. А может мне показалось, что согласился.

Менеджмент сообщил, что будет принимать решение о судьбе системы 2.0. И ушёл думать.

Заниматься дальше проектом у меня не было никакой мотивации. Но сидеть без дела было как-то неудобно. А вдруг всё наладится и меня спросят, а что я делал? Вадим уже не работал, я был сам по себе и мне пришла в голову совершенно дикая мысль. Прикрутить MySql. С точки зрения Вадима это было бы кощунством, потому что все данные в оракле, а саппортить вторую БД ресурсов не было. Но теперь мне было примерно похер. Я развлекался как мог.

Я залогинился на свой тестовый сервер под рутом.

apt-get install mysql-server

Прикручивание MySql на удивление прошло быстро и просто. На всё ушло дня три. После этого скорость работы с данными выросла ещё в несколько раз. ORM на сервере мог обращаться одновременно к ораклу и мускулю, стримя данные в протобуф. Клиент вообще не мог понять, откуда данные, там все репозитории выглядели одинаково.

Я вздохнул с облегчением. Несмотря на то, что я ускорил работу с ораклом, это работало только в проде. На локальной машине по прежнему царствовали боль и страдания, потому что от локального сервера до оракла был большой пинг. Ковыряние ручками в базе также тормозило. Мускулю же было наплевать на пинг, у него довольно эффективный бинарный протокол, не ждущий ответа от клиента. Единственное, что тормозило при задранном пинге - это последовательный инсерт с последующим SELECT_LAST_ID(). Это я тоже поправил, добавив батч инсерт и стало совсем хорошо.

Человечьи инты! Автоинкремент вместо сиквенса! Вменяемые текстовые типы, которые не ограничены 12к символов и не путают пустую строку с нуллом! Реордер колонок на уровне БД! А ещё мускульный датаридер позволял dr.GetInt32() по имени поля, а оракловый - только по ordinal. И там приходилось анбоксить всё из обжекта и конвертировать в целевой тип. И зачем вообще мы столько мучались, а, Вадим?

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

Я поинтересовался, смотрели ли ребята документы, ссылки на которые я им предоставил.

В этот момент я о себе много узнал. В третьем лице Саша обо мне рассказывал менеджменту неприятные вещи. Что я взял неправильную таблицу, не разобрался, что она для другого. Что я не умею в SQL. Что я развожу руководство на деньги и уже потрачено на меня 6 млн. Не надо выбирать из этой таблицы 8к записей. Она не для этого, он просто не разобрался. И вообще если ты выбираешь больше тридцати строчек из оракла, то ты делаешь что-то не так.

Саш, а это не твоя команда сделала выборку в 6к строк для комбобокса при каждом открытии окна и при каждом изменении параметров?

Я сделал последнюю попытку. Рассказал про объёмы документов поставки, которые нужны по ТЗ. Показал, что вот так тормозит, если запускаю сервер локально. И вот так всё летает, если подключу к серверу в облаке. Видите разницу? Это один и тот же код.

Второй раунд переговоров окончился ничем. Я забил на еженедельные совещания. Мотивировал тем, что это было поздно для меня (20-21 по МСК) и я на такое не подписывался. На начальственное "надо" я тоже забил. Настоящая причина была в Саше. Я не хотел с ним общаться, я не хотел выполнять его задачи.

На третьем раунде переговоров был Максим. Руководство попросило его рассудить нас. Если я мотивировал решение именно скоростью, то у Макса был совсем другой набор аргументов. Он опирался на свой опыт работы с разными системами и на примерах рассказывал, насколько трёхзвенка лучше двухзвенки. Он сказал, что решение строить трёхзвенку принял он. Конечно, он лукавит. Возможно он первый эту мысль озвучил, этого я не помню, но тогда мы вместе искали решение, а не Макс продавил своё.

Третий раунд закончился ничем. Менеджмент ушёл думать.

А я психанул. Попросил внеочередной двухнедельный отпуск. Первый за год. В октябре. Мне дали окно через неделю.

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

Договорились, что по выходу из отпуска, 6 декабря, я пишу по собственному.

Вот и всё. Всё кончено. Я проиграл. То, над чем я работал - больше никому не нужно. Всё было напрасно.

Вскоре одна птичка мне начирикала следующую историю:

Саша арендовал какой-то супермногоядерный многогигабайтный VPS в облаке яндекса. И воткнул туда Remote Application Streaming, чтобы запустить там клиента старой системы для каждого сотрудника. Оно даже иногда работало быстрее. На уровне эффекта плацебо. Но иногда сильно медленнее. И помните про эксель? Ремотный клиент требовал ремотного экселя для интеграции, что добавляло немало попоболи. И появились плюшки RDP вроде загрузки канала и тормозов стрима. Странно, конечно, что скорость не выросла. Возможно, виндовый зоопарк отгорожен от пингвиньего заборчиком и там тоже пинг.

Птичка потом тоже заявление напишет. Недели через две. Мотивирует невменяемостью нового руководства (Саши). Но пока что втихаря ходит по собесам.

Послесловие

Отпуск. Вечер рабочего дня. Официально я трудоустроен, а по факту уже уволен. Я сижу, пью пиво (алкоголь вредит вашему здоровью) и думаю. Как так получилось, что я не смог? Может быть я плохую систему построил? Может я плохие аргументы подобрал? Почему я не смог доказать, что я делал то, что нужно было делать? Почему мои убедительные аргументы оказались настолько ничтожными для Саши, как будто он рели...

Стоп.

Бооооже моооой. Как я раньше этого не замечал?

Воспоминание разблокировано:
Год и двумя месяцами ранее. Мы с (тогда ещё новым) аналитиком сидим в офисе компании и разговариваем с девочками-менеджерами. Нам показывают кусок функционала, где что-то отправляется посчитаться и оно там очень долго молотится. Может отвалиться. Может сломать соседние джобы. И вот вспомнилась одна фраза "Ну и когда мощная машина оракл нам доделает?". Сказано было с нескрываемым сарказмом. Тогда я не обратил на это внимание. Теперь я понял, откуда ноги растут.

Я понял, почему наш отдел называется "отдел разработка Oracle". Я понял, почему аналитический отдел бизнеса, не имеющий отношения к БД называется "Отдел аналитики Oracle". И группа в ватсапе у них "OraAnal". Я сейчас не шучу, если что.

Я понял, почему в документации конфы ехал оракл через оракл. Ора-то, ора-сё.

Как будто я в руках вертел крышечку и примерял к корпусу. А она "щёлк" и всеми защёлками одновременно встала в пазы и теперь плотно сидит. Без люфта. Вот такое странное чувство удовлетворения. Всё встало на свои места.

Я понял, о чём говорил Саша, когда говорил, что я не знаю Оракл. Я понял, что он был прав. Не может же быть проблем в Божественном Оракле! Если у тебя что-то не получается, то значит ты действительно что-то делаешь не так. Это надо понимать.

А я, дурак, допускал греховные помыслы. Скорости там какие-то, удобства, поддерживаемость, читаемость... Это всё мирское. Недалёкое. Безбожное.

Мне дали возможность исповедаться и принять Божественный Оракл в сердце своё. Предложили работать под присмотром братьев, Познавших Божественность Оракла. А я отказался. Да ещё и оскорбил своих братьев сомнениями. Искушал их грехом.

Вот в чём дело.

Получившийся в процессе написания статьи ORM я выложил в свой репозиторий. Это не готовый к продакшену продукт, это скорее проверка гипотезы. Концепт.

В репозитории пока что отсутсвует:

  • генерация эндпоинтов для селекта со стороны сервера

  • джоин таблиц на стороне сервера

  • отсутствуют поля для джоина один-ко-многим

  • поддержка LINQ на сервере

  • поддержка типов данных вроде bool, short, byte, char

  • интерцептор и авторизация по токену

  • структура для апдейта и инсерта объектов, вся система readonly

  • отслеживание изменённых полей сущности как для клиента, так и для сервера

  • батч инсерт

  • клиент-серверный трекинг на табличном уровне

  • отдельный трекинг, но по строкам документа, работающий по подписке (нужно было для совместного редактирования большого документа)

  • асинхронная загрузка потока

  • двухсторонний асинхронный стриминг данных

  • поддержка MySql в равной степени с ораклом прозрачно для клиента

Я решил пока это не делать, потому что восстановление всех фич займёт очень много сил и времени, а я даже не знаю, нужно ли это будет кому-то.

Если вам показалось, что я ругаю Вадима - то вам показалось. Вадим лапочка. Он всегда остывал и принимал разумное решение. А что касается микроменеджмента... Он исправится, обязательно исправится. Сейчас с ним всё хорошо. Он работает в другой компании.

Ссылки на телеграмм не будет. Я лучше в следующий раз пропиарю свой продукт для работы с БД в парадигме model-first. Когда допилю фичи, доделаю сценарии, допишу документацию и выловлю баги.

Спасибо, что дочитали до конца.
Удачи вам! Всем пше згыр!

UPD.

В комментариях есть вопросы про fetch_size. Я решил ещё раз проверить эту гипотезу и покрутить его. В интернетах советуют задрать его до 10к, но я точно помню, что мы это делали. И до 10к и до 30к.

У меня совершенно случайно есть исходники OrmFactory, я решил проверить там. Ставим 30к и брейкпоинт:

Ого, там уже 131к! Тогда попробуем побольше. Я поставил 300к и табличка загрузилась за 3 сек. Я поставил миллион и табличка загрузилась за 2 секунды. Целиком 288 рядов поместились только в три миллиона:

Признаю, в своё время не докрутили. Жаль, что сейчас уже это ничем не поможет.

Tags:
Hubs:
+21
Comments31

Articles