пятница, 4 сентября 2015 г.

Крупный рефакторинг приложения. Часть 2: База

В первой части я рассказывал о том как что нас сподвигло на большой рефакторинг приложения и как в общих чертах происходило переписывание кода. Хотя рефакторингом это (300+ файлов для чекина в main) назвать нельзя. Термин "рефакторинг" подразумевает "небольшие контроллируемые изменения", а не "отрежем пациенту ноги и пришьём новые, попутно переделав зрение, слух и часть внутренних органов".
Но как ни странно, изменения в коде были в целом довольно скучными и предсказуемыми, пусть и работы там было очень много.
А вот с базой дело обстояло несколько иначе.
Первоначально план был примерно таким:
1. Создать миграцию, которая сделает новые таблицы с префиксом "new", новый код будет работать с ними (на этапе отладки)
2. Написать приложение мигрирующее данные подокументно (отмечать старые документы в новой колонке Migrated), смигрировать их кроме последних месяцев.
3.  Быстро смигировать последние нескольком месяцев
4. Запустить миграцию добавляющую старым таблицам префикс "old", переименовать новые таблицы без префикса. Опять же с возможностью rollback-а миграции.
5. Вылить код, который ссылается на таблицы без префикса.

Эта схема мне показалась несколько громоздкой и я поинтересовался у коллег что они могут мне посоветовать. А посоветовали хорошую вещь - использовать namespace-ы у MS SQL сервера. Т.е. вместо колдования с переименованием таблиц просто создавать новые таблицы в другом namespace в духе "at.Document" вместо "dbo.Document". И имя у таблицы нормальное и работы меньше и меньше шанс что в процессе что-то пойдёт не так.
Так что план действий существенно сократился:
1. Написать миграцию для новых таблиц (с возможностью rollback). Код сразу будет смотреть на новые таблицы, нет необходимости что-то менять в последствии.
2.Написать приложение мигрирующее данные подокументно (отмечать старые документы в новой колонке Migrated), смигрировать их кроме последних месяцев.
3. Вылить код, быстро смигировать оставшиеся данные.

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

Приложение для миграции я писал "на коленке" - для чтения использовав Dapper, а для записи сделав friendly сборку с новым DAL, непосредственно манипулируя сущностями-отображением базы через Entity Framework. Причем первая реализация была написана довольно быстро (за несколько часов) и я довольный ушел домой на выходные (ведь практически всё готово, а я целую неделю на это закладывал!). Наивный.

В понедельник, слегка допилив то что я сделал в пятницу, я решил прогнать пробную миграцию. И сразу же упёрся в довольно плохую производительность - в тестовой базе около 7к документов и миграция шла несколько часов. А в продакшн базе 130к документов и 75 Гб файлы базы, что даже с учетом более мощного сервера грозило несколькими сутками. И это в случае если не будет деградации производительности.
А самое ужасное, что по ощущениям деградация производительности была - первые 20-30% документов проходили быстро, а потом всё становилось всё хуже и хуже.
Первое что я сделал - разбил миграцию меняющую структуру данных на две - первая создаёт таблицы с Primary Key, вторая (должна запускаться уже после миграции данных) создаёт индексы и внешние ключи.
Запускаю миграцию снова... И всё работает по прежнему медленно!
Начинаю думать, гуглить и спрашивать коллег. В итоге подкидывают идею насчет clustered (т.е. упорядоченных индексов) у первичных ключей. Учитывая что они преимущественно GUID-ы, получаем что на вставку каждой новой записи требуется всё больше и больше времени. Радостно переписываю индекс на non-clustered... И снова всё существенно замедляется через какое-то время!
Начинаю копать с точки зрения администрирования базы. Выясняю что рост базы настроен не попроцентно, а +1 Мб . Учитывая что многие документы сами по себе занимают более мегабайта, получается что довольно много времени может уходить на увеличение размера базы, которое будет происходить довольно часто. Меняю эту настройку... Да, да, ничего существенно не поменялось.
Уже проходит выделенная на миграцию базы неделя, а у нас по прежнему необъяснимая деградация после приблизительно 30% документов. Обращаемся к админам, те дают нам "почти продакшен" сервер, который в общем-то практически идентичен продакшену, но имеет не 140, а около 30 Гб оперативной памяти. Заодно накатывает туда нам копию базы из продакшена.
Теперь экспериментируем с настоящими данными (без риска их испортить по настоящему), первые 40к документов проходят буквально за 30 минут! Отлично, раз уж на тестовой базе всё начинало подтормаживать уже после 2к документов, то значит всё идёт хорошо и проблема в производительности сервера и его занятости. Как бы не так, после 40к записей скорость миграции снова снижается в разы.
Заодно на реальных данных всплыли 2 проблемы:
1. Зачастую приложение по миграции данных потребляет слишком много памяти (на очень больших документах). Становится понятно что идея "а давай будем использовать один контекст базы для миграции всех документов" была явно плохой. Да и производительность совсем не улучшалась.
2. Есть несколько документов (возможно битые данные в базе, точно не знаю), у которых аж по 160к сегментов. Тут даже подокументное использование контекста не помогает. К тому же чем больше документ, тем больше времени на его миграцию требуется (похоже что нелинейно). В общем, работа с контекстом базы была переписана на использование кусков по 1к сегментов, после чего идёт пересоздание контекста.

Попутно Алексей пытался сделать миграцию другими способами. В общем и целом - не очень помогает.
В общем, мы решили прогнать таки миграцию хоть раз, решив таки дождаться. Примерное время исполнения получилось бы меньше 12 часов, что в общем и целом нас устраивает. Ухудшение производительности происходит только один раз, дальше замедление останавливается.
Попутно со мной связывается админ и говорит что "вы ребята как-то поскромнее будьте, это вообще-то сервер отчётов, а вы его своими миграциями мучаете". Так что совсем до конца миграцию мы так и не успели прогнать, выгнали нас оттуда :)

Через какое-то время я вспоминаю что ковырял непонятный и кажется неиспользуемый код со странным названием "деархивация документа". И тут меня осиняет догадка, которую я сразу же проверяю. Ну конечно же - код по архивации документа делал следующее - он вытаскивал все сегменты документа, сохранял их в файл (кстати, надо бы выяснить куда) и удалял их из базы. И конечно же, на продакшен сервере эта операция последний раз выполнялась примерно так в 2012 году и количество заархивированных документов как раз приблизительно равняется сорока тысячам! Т.е. по факту никакой деградации производительности не было - просто самые старые записи (а начинал я от старых к новым для того чтобы по максимум избежать возможных коллизий) были без данных. И только потом начиналась нормальная работа.
Кстати, по мере тестирования нашего кода выяснилось что некоторые операции выполнялись очень даже медленно (там был подсчёт статистики по нескольким документам за раз). Я уже боялся что придётся хранить агрегированную статистику внутри документа (а очень не хотелось, т.к. добавляло много работы и денормализовывало данные), но добавление нескольких индексов улучшило ситуацию на 2 порядка до довольно приемлемых половины секунды (на тестовом сервере, всё равно эту функциональность редко используют). Для меня было новостью что внешние ключи оказывается не обязательно создают индексы и их стоит создавать вручную. Ну и последующее создание индексов на полной базе занимало порядка 40-50 минут, что тоже стоит учитывать и выливаться в ночь на выходные, чтобы утром создать индексы не мешая пользователям. Кстати, учитывая что сервер изрядно вытаскивает базу в память, скорее всего стоит еще и очистить статистику для старых таблиц + перезапустить его.

Крупный рефакторинг приложения. Часть 1 - Код.

У меня получилось нечто типа отпуска, так что появилось время, силы и желание на графоманию.
Хотелось бы рассказать о моём довольно интересном опыте по переписыванию половины проекта "одним куском", т.к. по частям уже не получалось (либо выходило слишком опасно, криво и долго, а времени у меня уже не оставалось).

Краткое введение

До недавнего времени я работал на проекте для онлайн перевода документов.
Т.е. упрощенно - пользователь загружает документ (docx, xlsx, indd, xml, ts, xliff и т.д. и т.п.), наше приложение его парсит, разбивает на т.н. сегменты - куски удобные для перевода (чаще всего - предложения), даёт веб-интерфейс для этого самого перевода (с редактором и уймой всяких других плюшек облегчающих жизнь переводчику) и потом пользователь может загрузить себе результат - переведённый документ.
Основные сущности это Document - собственно документ, Segment - сегмент документа, по факту всегда используется вместе с вторым Segment - один для исходного текста (Source), второй для переведённого (Target). Вместе они образуют (точнее, должны бы были образовывать) TranslationUnit - по факту минимальная единица с которой приходится иметь дело.
С технической точки зрения всё куда сложнее. Сразу хочу сказать что отлично понимаю что проект разрабатывался давно и разными людьми, поэтому весь этот бардак вполне понятен. Так что я никого не виню и вообще меня интересует не "кто виноват?", а "что делать?".
В общем, это изначально было ASP.NET WebForms приложение, которое постепенно мигрировало в сторону MVC-подобного решения с помощью handler-ов и ajax-запросов с клиента. За последние полтора года удалось довольно многое причесать, но некоторые вещи подпиливались преимущественно костылями. Например, это был слой работы с базой - там был зоопарк из DataSet-ов, вызовов хранимок, собранного вручную (для фильтрации и пейджинга) SQL кода дёргаемого через ADO.NET. Я уже писал о том что поддерживать это было очень трудно и всегда вылазило довольно много багов.

Так что, у нас было 3 основных задачи:
1. В CRM с которой мы интегрировались существует довольно много различных стадий обработки документа - перевод, валидация, вычитка, исправление замечаний по валидации и т.д. и т.п. (порядка 17 штук). Наша же система ничего об этом не знала - у неё есть только 3 жестко прописанных стадии с некоторыми различиями в поведении - перевод, валидация и вычитка (в базе - по колонке на каждую из стадий). Так что в итоге все стадии из CRM сводились в одну из трёх стадий у нас. Что привносило заметные неудобства, т.к. прогресс и прочее по факту не отражали нужной картины. Не говоря уже о простынях switch-ей для отображения нужной статистики. Так что основной нашей задачей было ввести т.н. Stage - этап обработки документа, который бы мог соответствовать любому из нужных вариантов в CRM.
2. Текущее состояние слоя персистентности в принципе не позволяло реализовать это в какие-то разумные сроки с разумными затратами. Множество хранимок, запросов, датасетов смешавшись в чудесный винегрет превращало процесс изменений в ад. Так что было принято решение (точнее, я топал ногами и кричал что иначе я за это не возьмусь) заодно написать новый слой персистентности на Entity Framework, изменения в котором было бы легко контроллировать
3. Структура базы была заметно переусложнена и обросла многими артефактами. Например вместо простой связки Document -> TranslationUnit была связка Document (у которого две ссылки на одну таблицу Source + Target)  -> Translation -> Segment, так что банальная задача вычитки TranslationUnit-ов превращалась в запрос с минимум 4мя джойнами (по 2 штуки - Document -> Translation -> Segment), не говоря о довольно частой ситуации с вызовами GetTargetSegment(..) и сразу за ним GetSourceSegmentByTargetId(...). В общем, текущий подход ухудшал и производительность и читабельность. И вообще, служил отличным примером о вреде предварительного обобщения - явно было сделано с заделом на какую-то функциональность которая в итоге так и не понадобилась.

В общем и целом, реально поставленной перед нами задачей была именно первая. Но реализовать её без второй и третьей было нереально (во всяком случае силами полутора землекопов разработчиков). В идеале, вторую и третью задачу было желательно решить раньше, в отдельном спринте с отдельной выливкой и т.д. Но кто бы выделил нам минимум месяц на рефакторинг не приносящий практически никакой прямой пользы для бизнеса? Собственно, даже на первую задачу (внутрь которой мы внесли оставшиеся 2 с соответстующими оценками по времени), которая "urgent-immediate-ASAP" время выделили только через 3 или 4 месяца после её возникновения и формулировки (долго, видите ли).

Процесс миграции кода

Для того чтобы всё переписать был специально разработан план по выполняемым задачам:
1. Создать 2 отдельных ветки - в одной находилась миграция данных, а во второй - собственно код. Раздельные ветки были выбраны потому что миграция данных должна была проходить раньше чем выливка кода, возможно даже с разницей в неделю между выливками.
2. Создать миграцию базы в которой бы создавались новые таблицы. У неё обязательно должен быть метод Down(), который бы убивал эти таблицы и приводил базу к состоянию "до миграции". Это довольно важный нюанс, т.к. на практике выяснилось что в процессе обкатки приходится довольно часто что-то подправлять или менять. И простейший способ был "откатить миграцию -> поправить её -> накатить заново". Так что это в итоге сэкономило нам довольно много времени.
3. Создать слой работы с базой (в отдельном проекте), тут я описывал общие принципы того как реализовал его - наружу был видим только четкий контракт и возвращаемые объекты гарантировали что они будут заполнены (т.е. если какие-то данные мы не вытаскиваем в конкретном случае, то результирующие объекты будут без соответствующих полей)
4. Переписывать текущие методы вызывающие старый код на новый, попутно по возможности правя модели идущие на верхние слои. При этом методы из старого DAL удалялись чтобы удостовериться что больше нигде не используются. И так до момента когда всё начнёт компилироваться. После этого можно было бы хотя бы сделать check in хотя бы компилирующегося кода. Благо это не было большой проблемой, т.к. всю работу в это время делал я один.
5. Первичная стабилизация проекта - прогнать набор тестов + проверить руками что основная функциональность работает.
6. Написать миграцию из старых таблиц в новые, сделать миграцию тестовой базы.
7. Прогнать автотест нашего QA и стабилизировать до более-менее нормального состояния
8. Сделать тестовую миграцию более крупных данных объёмов данных, которые ближе к реальности.
9. Отдать на ручное тестирование, вычистить последние баги.
10. Прогнать миграцию на продакшене в фоновом режиме, пока еще работает старый код со старыми таблицами
11. Вылить код на продакшн, перемигрировать изменившиеся данные (их должно быть немного)

В общем и целом, процесс переписывания кода был относительно тривиальным и заключался приблизительно в следующих шагах:
1. Берём какой-нибудь метод из старого DAL, смотрим где он используется.
2. Идём по его вызовам и меняем их на вызов нового DAL - как минимум первое время это выливается сперва в написание классов-маппингов к базе, потом написание классов-моделей, потом в написание соответствующих методов для специфичных CRUD потребностей.
3. В вызывающем коде конвертируем "доменную" модель в модель слоя персистнентности или обратно (что чаще).
4. Большая часть методов теперь стала требовать не только DocumentId/SegmentId, но еще и StageId. Так что менялась их сигнатура и по некомпилирующемуся коду поднимался до самого верха, не забывая потом поискать текст "handlers/Имя_хэндлера" в яваскриптовой части. Для случаев когда вместо Guid documentId надо было передать Guid stageId, специально добавлялся дополнительный параметр int? stub, чтобы не пропустить передачу чего-либо иного. Т.е. относительно легко пропустить то что теперь должен передаваться совсем другой Guid, но сложно это сделать когда вместо одного параметра теперь надо передать два, пусть второй и банальный null. 
5. Попутно зачастую приходилось добавлять промежуточные модели, т.к. довольно часто модель из DataSet-ов передавали напрямую до View, которая оказывалась пронизана чем-то типа 
x.Id = row["Id"];
и подобной радостью с вытаскиванием данных по строковым именам. Что естественно приводило к рантайм ошибкам уже на этапе тестирования, т.к. на этапе компиляции не отлавливалось, а для того чтобы самому просмотреть все хитрые места у меня недостаточно хорошая память.

В общем, процесс был относительно несложным, но до момента "наконец-то оно компилируется" у меня ушла неделя довольно напряженной работы.
Как ни странно, тесты удалось завести в течении буквально пары-тройки часов. Причем чуть ли не половину этого времени заняла починка буквально 3-4 тестов (из прорядка 2-3 сотен) сделанных с помощью Mock-ов. Так что считаю что моя позиция по поводу нелюбви к Mock-ам, фейкам, stub-ам и прочему вполне обоснована.
После этого я еще день или два стабилизировал относительно мелкие проблемы до состояния "проект кажется заработал". Это, кстати, обрадовало по 2м причинам:
Во-первых, изначальная идея "как всё это должно работать" оказалась жизнеспособной, концепция Stage нормально легла на код и заодно поспособствовала удалению лишних частей.
А во-вторых, подход "максимально ограничиваем классы только тем что они могут и должны делать" привёл к тому что починка багов заняла куда меньше времени чем собственно правка кода для того чтобы он изначально скомпилировался. Т.е. большую часть работы удалось переложить на систему типов, а не на вылавливание ошибок в рантайме.
Вместо собираемого вручную SQL-кода для фильтрации, сортировки и пейджинга был сделан специальный класс состоящий из нужных параметров (преимущественно enum-ы + строка фильтра). Этот класс передавался в специальный метод в новом DAL, который у себя внутри собирал IQueryable в зависимости от параметров и возвращал уже готовый результирующий список. Новый код получился во-первых меньше чем старый, а во-вторых заметно более понятный, а в свете рефакторинга базы еще и генерировал лучший SQL код чем тот что был написан вручную раньше.

После чего, я занялся непосредственно кодом занимающимся миграцией базы, что будет темой для отдельного поста, т.к. там неожиданностей и всяких нюансов всплыло намного больше.
Когда же миграция была более-менее готова, мы решили прогнать полный автотест (это занимает порядка часа) и в целом за день-два сумели заставить его проходить без ошибок. Это были как раз последние дни моей работы в этой компании. Я из интереса посмотрел что для merge в MAIN ветку будет около 310 файлов, это одно из крупнейших изменений делаемых "одним куском" из тех что я когда-либо делал. Впрочем, это далеко не первый мой опыт по переписыванию DAL приложения, может быть поэтому я и был уверен в успехе.
Сейчас, на момент написания статьи, идёт уже ручное тестирование с правкой оставшихся багов и процесс близок к завершению. Надеюсь что меня позовут на миграцию базы и выливку кода, я вложил очень много сил в этот рефакторинг. Во всяком случае уверен что сделал довольно большую работу для того чтобы облегчить жизнь тем людям которые будут разрабатывать проект в дальнейшем.

Часть 2

пятница, 28 августа 2015 г.

Сказ о том как мы выливались до 3х часов ночи

Однажды у нас появилась относительно несложная (на вид) задача - отображать прогресс перевода документа не по количеству переведённых сегментов (относительно небольшие куски на которые разбивается документ для более удобной обработки), а по количеству слов в этих сегментах. Идея здравая, информация насчёт слов у нас вроде бы и так была. В целом казалось что сделать всё проще простого.
Однако реальность внесла свои коррективы. Сразу же выяснились 2 вещи:
1. Старая статистика хранилась в уже агрегированном виде в таблице документов и обновлялась при каждом изменении сегмента (что увеличивает количество запросов при обновлении, потенциально опасно и ухудшает и так не слишком читаемый код внутри персистентности, но это лирика)
2. Количество слов у сегмента у нас было только в C# коде (высчитывалось по примитивному алгоритму) - в базе эти данные не хранились, только общий агрегат.

Так что мы поняли что придётся всё же писать количество слов для каждого сегмента + обновлять кусок, который считает статистику для документа.
Это был один из первых звоночков, когда я стал понимать что так дальше жить нельзя - вся логика работы с базой была размазана по немалому количеству хранимых процедур, причем искать зависимости в базе - это совсем не find usages внутри студии. При этом даже статистика вытаскивалась как минимум двумя разными хранимками (мы с этим отлавливали баги - в одном месте были одни значения, а в другом - другие).
Но в общем и целом кое-как была таки дополнена логика на сервере, на клиенте, написана миграция базы переписывающая штук 5 хранимых процедур и вьюшку, а так же добавляющая недостающую колонку. Ну и следующей миграцией (накатываемой сразу же после первой) был написан SQL скрипт, который с помощью несложной функции считающей количество слов (аналог string.Split('  ').Count() в нашем коде) проходил по всем сегментам и прописывал количество слов в них.
После довольно немалого (как для такой несложной фичи) периода стабилизации на тестовом сервере мы таки решили выливаться в продакшн. По грубым прикидкам (банально поленились полезть и посмотреть, положившись на память) на продакшене было раза в 3-4 больше данных чем на тестовом сервере. Учитывая что скрипт на тестовом сервере шел порядка 5-10 минут, мы решили что в целом ничего ужасного нет и мы вполне можем подождать полчасика.

И вот наступает день Х. Мы ждём пока выльется другая команда, у них это затягивается до 10-11 вечера (как обычно, впрочем). После чего начинаем свою выливку - быстренько выливаем код и запускаем миграцию. Первая миграция (со структурой базы) проходит быстро, а вот вторая как-то затягивается - 30 минут, 40... В итоге примерно к концу часа, миграция падает с довольно невнятным сообщением об ошибке. Ладно, пробуем прогнать её еще раз и попутно пытаемся разобраться в чем там было дело. Толком непонятно, но к концу следующего часа миграция опять падает. Становится понятно что это не случайная ошибка, а какой-то систематический просчёт у нас в миграции.
Хуже того, Sql сервер начинает как-то уж очень плохо шевелиться, причем не только на нашей базе. Делать нечего, вызваниеваем одного из админов. Он сообщает что раздел диска выделенный под TempDB забит под завязку. После чего добавляет туда места, рестартует сервер и вроде бы всё начинает нормально работать. Уже около половины второго ночи. Мы выдыхаем чуть спокойнее и решаем что нам необязательно "вот прямо сейчас" иметь все данные обновлёнными и поэтому достаточно будет если мы смигрируем документы только за последние несколько месяцев, а остальное отложим на завтра. Собственно, это мы и сделали - в ручном режиме (запустив скрипт напрямую) прогоняем миграцию только части данных, быстренько прогоняем тесты и разъезжаемся по домам.
Утром я быстренько набросал консольное приложеньице, которое мигрирует сегменты подокументно (т.е. пачками 10-10к штук) и уже в рабочее время в течении дня прогнал миграцию, на всякий случай попросив админов мониторить нагрузку на сервер (там всё оказалось нормально).
Т.е. в общем и целом - особо ужасного конечно ничего не случилось - вряд ли много пользователей страдали от того что наш сервис плохо работал в районе полуночи, никаких данных мы не потеряли и к утру большинство сценариев даже отрабатывали как надо.
Но для нас это было неприятно и нетипично - крайне редко выливки у нас длились дольше чем до 20:00, при том что начинались не раньше чем в 18:30, а 90% времени тратилось на регрессивное тестирование автотестом + немного руками.

Так что мы сделали кое-какие выводы:
1. Иногда размер (базы) таки имеет значение. То что довольно легко проходит на тестовых данных, может застрять на реальных объёмах.
2. Всегда стоит внимательнее посмотреть - насколько отличаются размеры тестовых данных и насколько может деградировать производительность с ростом их количества. И если миграция на тестовом сервере проходит больше 3-5 минут, то это уже повод для беспокойства, особенно если при увеличении количества записей время растёт нелинейно (проще всего это проверить запустив миграцию сперва на части тестовых данных, а потом на всех).
3. Слона лучше есть по частям - не стоило запихивать такие масштабные изменения в один запрос. Именно это привело к распуханию TempDB. Так что следующие масштабные миграции мы решили делать в фоне и подокументно, т.е. относительно небольшими порциями. Кстати, мне стоило послушать Лёху, который предлагал это изначально ;)
4. Не совсем относящийся к миграции пункт - слой работы с базой был крайне плохоподдерживаемым. И мы затратили уйму сил чтобы всё это заработало и заработало без багов. И это на довольно простой задаче. Так что зачастую косвенные затраты на внесение новых фич в плохо поддерживаемых участках кода могут в совокупности довольно быстро перевесить затраты на нормальное переписывание. Надо будет не забыть про этот аргумент в следующий раз, когда буду выбивать время на рефакторинг кода.

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

среда, 26 августа 2015 г.

О Persistence ignorance и проблеме контроля изменений в слое работы с базой

О Persistence ignorance и проблеме контроля изменений в слое работы с базой.
В огромном количестве умных книжек по доменным моделям постоянно твердят о т.н. “Persistence ignorance” - утверждении о том, что доменная модель должна строиться без оглядки на базу данных. Мысль конечно правильая, вот только в качестве реализации почему-то всегда предлагают сделать ORM маппинги для доменной модели и делать вид что она при этом никак с персистентностью не связана, “оно само” работает с помощью ORM.
Однако даже в простейших “книжных” примерах или в демонстрационных видео видно что уже сразу начинаются всяческие уступки для того чтобы ORM заработала - virtual свойства, proteсted конструкторы без параметров (для того чтобы была возможность заполнить поля при вычитке из базы), использование обратных ссылок на родителей в дочерних объектах (public ParentClass Parent {get; set;}) для того чтобы включить инверсное сохранение дочерних объектов так как оно работает в СУБД (проблема one-to-many) и т.д. и т.п. Т.е. делается множество относительно мелких, но всё же вполне заметных допущений и “подпиливаний напильником” для того чтобы суметь втиснуть доменную модель в рамки ORM и работы с базой. Причем допущения эти в целом довольно неявные и неочевидные, но без них персистентность перестаёт работать.
И это еще в хорошем случае если доменная модель и структура базы совпадают между собой. Когда же начинают натягивать что-то не совпадающее 1 в 1, то в ход идёт тяжелая артиллерия - всяческие ухищрения со стороны маппингов и настроек ORM. В этом случае персистентность становится неимоверно хрупкой. И без знания этих деталей сделать изменения в базе или доменной модели становится очень опасно - могут возникать ошибки в абсолютно неочевидных местах.
Поэтому, лично моё мнение что в таком виде Persistence ignorance - это миф, слишком уж разные миры реляционных баз данных и объектов (и коллекций) в памяти.

Что же делать в этом случае? Перестать притворяться!
Т.е. разделить условный слой персистентности на две части:
Часть работы напрямую с базой: объекты-модели максимально точно воспроизводящие строение базы, их маппинги (или напрямую аннотации в классах), контекст работы с базой. Всё это имеет модификаторы internal и снаружи не доступно.
Часть, свободная от персистентности - уже “чистые” классы, которые будут нашим контрактом во внешний мир + публичные статические методы, которые создают, удаляют и обновляют записи в СУБД. Именно они манипулируют нашей первой частью в том виде, в котором нам это нужно.

Т.е. всё это выглядит примерно таким образом:
Сперва мы создаём “образ” нашей СУБД в виде всем понятных и известных классов и маппингов, не пытаясь притворяться что “ложки-базы нет” - это именно что модель сугубо для общения с базой, с публичными сеттерами и прочими вещами не свойственными для хорошей доменной модели. Там же мы создаём internal перегрузку контекста работы с базой (сессия в терминах NHibernate), чем сразу говорим “работа с базой дальше этой сборки не пойдёт”, заодно избавимся от соблазна протянуть Entity/NHibernate через всё приложение.

После этого можно приступать к созданию классов доступных уже за пределами проекта, не завязанного напрямую с базой, не имеющих маппингов, конструкторов без параметров (а то и вовсе не имеющих публичных конструкторов, только private/internal, если нам не надо создавать объекты без сохранения их в базу). 
Лично я создавал вообще read-only объекты, модификация которых была возможна только “через кассу базу” .

Т.е. выглядело это приблизительно так:
public class Document
{
 private readonly Guid id;
 private readonly string title;
 private readonly string fileName;

 internal Document(Guid id, string title, string fileName)
 {
  this.id = id;
  this.title = title;
  this.fileName = fileName;
 }
 
 public Guid Id {get { return id; } }
 public string Title {get { return title; } }
 public string FileName {get { return fileName; } }

 public static Document GetDocument(Guid id)
 {
     using (var context = DbContext.CreateInternal())
     {
         var entity = context.Documents.Find(id);
  
         return ConvertFromEntity(entity); 
     }
  }

  internal static Document ConvertFromEntity(Entities.Document entity)
  {
      return new Document(entity.Id, entity.Title, entity.FileName);
  }
}

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

Отдельно стоит упомянуть проблему поддержания всех моделей (при подходе по типу CQRS, где модель для чтения и модель для записи - это две разных модели) в консистентном состоянии. Думаю, для многих не редкость ситуация, когда для того чтобы добавить колонку в таблицу, необходимо не только добавить колонку в таблицу и поле в модель, но еще и добавить её в несколько view и хранимых процедур в базе. Причем узнать какие именно это хранимые процедуры или вьюшки довольно непросто - в Sql Management Studio есть конечно view dependencies, но работает оно мягко говоря совсем не так удобно как Find Usages в студии, не говоря уже о том что это модальное окошко. В итоге, вносить изменения становится намного труднее, а отлавливать баги - куда чаще.

В случае с вышеприведённым подходом, есть возможность использовать одну и ту же инфраструктуру для работы с базой как для чтения, так и для записи, ведь всё равно она лишь повторяет схему базы.
К тому же, по моему опыту - большая часть view - моделей обычно состоят из плоских доменных моделей + каких-либо дополнительных данных (агрегатные данные типа сумм, количества и т.д. + какие-либо дочерние коллекции). Соответственно, никто особо не запрещает использовать либо наследование (хотя его не очень люблю и на свежую голову решил что необходимости в нём тут абсолютно нет) либо композицию и использовать те же самые CreateFromEntity. Что во-первых даёт полный контроль с т.з. системы типов и конструкторов, а во-вторых уменьшает количество точек где надо производить изменения до минимума - в таком случае для того чтобы добавить колонку в базе необходимо будет: добавить собственно колонку, добавить поле в entity для работы с базой, добавить поле во внешний объект и его конструктор, а также в CreateFromEntity (который, кстати, тоже может быть private/internal конструктором).

Т.е. если нам понадобился документ вместе с его содержимым, то мы создаём 


public class DocumentWithEntries
{
 private readonly Document document;
 private readonly IEnumerable<DocumentEntry> entries;

 private static DocumentWithEntries(Document document, IEnumerable<DocumentEntry> entries)
 {
  this.document = document;
  this.entries = entries;
 }

 public Document Document { get { return document; } }
 public IEnumerable<DocumentEntry> {get { return entries; } }

 public static DocumentWithEntries GetDocumentWithEntries(guid id)
 {
  using(var context = DbContext.CreateInternal())
  {
   var documentEntity = context
                           .Documents
                               .Include(x => x.Entries)
                            .FirstOrDefault(x => x.Id == id);
   return ConvertFromEntity(documentEntity, documentEntity.Entries);
  }
 }
 
 private static DocumentWithEntries ConvertFromEntity(Entities.Document document, 
IEnumerable<Entities.DocumentEntry> entryEntities)
 {
  var document = Document.ConvertFromEntity(document);
  var entries = entryEntities.Select(x => DocumentEntry.ConvertFromEntity(x));
  
  return new DocumentWithEntries(document, entries);
 }
}

Т.е. если вдруг понадобится добавить поле в Document, то сперва добавляем его в Document и соответственно его конструктор (ведь поле у нас readonly, иного способа его инициализировать нет). А дальше - у нас перестаёт билдиться ConvertFromEntity, т.к. внутри в вызове конструктора не хватает параметра, вслед за ним тянется Entities.Document (надо же откуда-то брать данные?) и всё! Останется лишь добавить колонку в базу, что в общем-то довольно сложно забыть. Всякие DocumentWithEntries, DocumentWithStatistics и всё что нам еще придёт в голову будут автоматически иметь нужное нам поле. При этом Entity позволяет получать довольно удобные агрегирующие функции прямо из Linq интерфейса путём трансляции этих запросов в базу. Т.е. в 90% случаев скорее всего вполне можно обойтись без Views/Stored Procedures и ручного написания SQL кода.

В качестве вывода хотел бы еще раз подчеркнуть что не стоит пытаться прятать голову в песок и утверждать что мы имеем Persistence Ignorance, потому что мы сумели кое-как натянуть ORM поверх доменных объектов. На мой взгляд лучше не пытаться притворяться и разделить эту часть на две - проекцию базы данных и нормальные доменные сущности, которые уже будут действительно POCO, без прикрученных сбоку маппингов, конструкторов без параметров и т.д. Заодно, поверх этой же проекции можно сделать и View - модели для чтения. Либо смешав их с доменными (т.к. они практически всегда строятся поверх уже имеющихся там данных), либо вынеся в отдельную friendly сборку. Это поможет иметь куда более удобный контроль над изменениями в структуре базы, практически ничего не останется на “это надо запомнить”, всё будет контроллироваться с помощью параметризированных конструкторов и системы типов.

UPD. Тут мне заняли хороший вопрос - а что происходит с этой моделью в дальнейшем и как будет выглядеть модификация данных?
В общем, первоначальным соблазном было сказать что на выходе мы и имеем доменную модель. Но тут возникает вопрос - а что мы хотим получить от доменной модели и что в таком случае будет ею являться?
Если она должна "делать зашибись", включая обновления базы, то наверное можно назвать её и таким образом. Но мне не нравится эта идея, потому как тогда эта доменная модель будет  намертво привязана к персистентности и зачастую смешивать собственную логику с логикой персистентности (во всяком случае соблазн будет крайне велик).
Так что я всё же склоняюсь к тому что это никакая не доменная модель, а просто довольно четко оформленная модель персистентности. А уже потом из неё можно сделать что угодно - и доменную модель и модель для отображения данных и чёрта в ступе. Т.е. опять приходится не лениться и делать разделение между моделями :(
Соответственно, изменения для такой модели тоже могут быть минималистичными с учетом необходимых юзкейсов. Например, если необходимо изменить текст в элементе документа, то вовсе не надо принимать в качестве модели весь элемент документа, достаточно просто получить его Id и новый текст.
public static DocumentEntry UpdateEntryText(Guid id, string newText)
{
    using(var context = DbContext.CreateInternal())
    {
        var entryEntity = context.DocumentEntries.First(x => x.Id == id);
        
        entryEntity.Text = newText;
        context.Save();

        return ConvertFromEntity(entryEntity);
    }
}

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

вторник, 30 августа 2011 г.

How to get my music back from google

When i got invite to google music i decided to make good thing- write all tags to my music(some songs was too old and hasn't tag, other tags wasn't in unicode). I made big work(about 2-4k of songs needed editing) and was very glad. Except one little thing- i couldn't got my music with pretty tags back!
I started to investigate how i can do this:
1. Any open api from google, may be because this beta, may be copyright
2. Tried to get something from web-app. Any chance- about 460k obfuscated and compressed javascript. Too much work
3. Beta android music app can download files for offline listening. I'm not very good in android< but that was my chance. And it worked- any real protection.
What i found:
Files contains in sd card at directory /sdcard/Anrdoid/data/com.google.android.music/cache/music
Other players don't see it . Thanks xda developers- there need to delete hidden file with name .nomedia.But files there named with 1,2...3459 and contains old tags. So, music application store that data at other place. I found it at /data/data/com.google.android.music/databases/music.dbThis is simple unsecured SQLite base which contains all data about tracks in 'music' table.So, there wasn't big problem to write simple desktop(due to limited resources at android) application which get DB, directory with android music predownloaded mp3's and sort files by directories and with fulfilled tags.

Published with Blogger-droid v1.7.4

воскресенье, 14 июня 2009 г.

Свежие привычки

Преамбула:
Купил себе зимой PocketBook 301 plus. Для тех кто не в курсе-это электронная кижка, которая отображает текст практически как на бумаге. Читает кучу форматов, но дело сейчас не в этом. Есть у нее маленькая особенность-для экономии энергии реализовано автоотключение через 5-10-20 минут. И вот для того чтобы книга не отключилась, нужно нажать какую-либо кнопку.
Амбула:
Дали мне в iStore журнальчик с их рекламкой. Пришел домой и перед чаем решил почитать. Заварил чай, отложил журнал и пью себе спокойно. Тут проскакивает мысль:
"Надо журнальчик почитать, а то закроется!"

вторник, 17 марта 2009 г.

Размышления о механизмах персонализации и анонимности в интернете

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

1. Создается универсальное локальное хранилище данных, которое позволяет сохранять различные данные.  Примерный список особенностей и функций:
  • Хранение данных в зашифрованном виде
  • Данные хранятся в древовидном виде. Неважно, будет ли это xml или лисповые списки, сделать конвертацию будет нетрудно.
  • Данные каждого сайта хранятся отдельно друг от друга для предотвращения кражи информации. При этом должна быть возможность создавать доверенные группы, которые будут иметь доступ друг к другу, т.к. в том же гугле есть множество сервисов. Я еще не сильно задумывался на эту тему.
  • Необходимо некое api для управления хранилищем: простейшие операции добавления/удаления/редактирования и чтения+некий набор операций для группировки, обход дерева и т.д. Это api  и будет использоваться для общения сайта с хранилищем.
  • Генерация закрытых и открытых ключей для идентификации сессии и шифрования траффика
2. На веб-серверах должна быть поддержка этой технологии в том смысле, что опционально должен быть отказ от логинов, поддержка шифрования траффика и установки связи при запросе клиента. Так же сервер должен знать, как обращаться с хранилищем на стороне клиента. Т.е. вместо собственной базы статистики будет использоваться клиентская через соответсвующий высокоуровневый(?) api. 
Таким образом, работа системы будет выглядеть примерно следующим образом:
  1. Клиент обращается к серверу в первый раз. Сервер дает обычный ответ+информацию о том, что он поддерживает технологию анонимной персонализации. Клиентская часть (вероятнее всего интегрированная с браузером) выдает пользователю сообщение, что данный сайт поддеживает такую возможность. 
  2. В случае подтверждения пользователем, клиентская часть создает хранилище для данного сайта. 
  3. Также клиентская часть генерирует пару ключей, одна из них будет отослана серверу, а вторая будет использоваться для шифрования исходящего траффика
  4. Клиент отсылает открытый ключ серверу,  сервер генерирует свою пару ключей и отсылает открытый клиенту. С этого момента начинается обычная работа системы по зашифрованным каналам, клиентская программа переходит в пассивыный режим. Эти ключи используются как аналог временного логина, через некоторое время они устаревают и процедуру нужно повторить. 
  5. В случае возникновения необходимости у сервера для сохраненияили чтения какой-либо информации, он будет просто возвращать страницу с необходимыми данными или командами в блоке страницы. Простой парсер на клиенте будет выполнять эти команды в api клиента  и вернуть данные серверу в запросе.  Структуру данных определяет софт на сервере, так что это будет похоже на обычную работу с СУБД.
Это первоначальный набросок, который описывает всю систему, есть еще некоторые белые пятна, над которыми надо думать дальше.