2015-09-20

О схеме

Все мы любим схемы. Схемы данных. Схемы данных в реляционных БД. Схемы вызовов и сущностей в API. И прочее, и прочее.
Началось, всё, пожалуй, со структур. Структур в сишном смысле, ну или записей в паскалевом смысле. До структур у нас были примитивные типы да коллекции, вроде лиспов-списков. Структура же объединяет несколько значений примитивного типа в нечто цельное и обладающее отдельным смыслом. Структура — это схема. Схема размещения данных в памяти. Чтобы к каждому примитивному значению можно было удобно получить доступ по человекопонятному имени. И без этого, пожалуй, никуда. Из структур, как известно, выросли ООП классы, как минимум в некоторых языках.
Как нам добавить еще одно поле в структуру? Буковка O в SOLID принципах говорит нам о том, что нам нельзя просто взять и добавить поле в существующую структуру. И это правильно. Мало ли, куда нам заблагорассудится засунуть это поле. А схема размещения данных в памяти изменится. И ранее сохраненные данные уже будут не совместимы с новой схемой. Ой.
В классических сишных структурах поэтому, как правило, допустимо добавлять поля только в конец структуры. А в объектно-ориентированном C++ для расширения структур/классов придумали наследование. Наследуем базовый класс и добавляем в наследника новые поля. Хитрый C++ размещает поля наследника строго после полей родительского класса. Это нужно для выполнения буковки L тех же SOLID принципов. Если уж мы имеем указатель на область памяти наследника, то код, который умеет работать только с родительским классом, должен успешно поработать и с этим указателем.
Однако, сейчас в моде динамические языки, вроде Python и Ruby. И у них объекты в памяти, внезапно, не представлены жёсткой структурой. Объект — это словарь, он же карта, он же ассоциативный массив. Имя поля (строка) связано со значением этого поля. Как это физически представлено в памяти, уже не важно. Важно, что у нас есть чётко определённый способ получить значение по имени.
И тут возникает феномен утиной типизации. Какому-либо коду, работающему с объектом, уже не важна схема размещения свойств объекта в памяти (класс), а достаточно лишь наличия некоторых свойств с определёнными именами. В разном коде требуются разные свойства. Но класс объекта действительно уже вторичен. Именно поэтому в том же Python почти не встречаются длинные иерархии классов, в отличие от Java. И это удобно.
Duck
Схемы объектов в памяти — это еще полбеды. В конце концов, когда мы меняем код, мы перезапускаем процесс, и всё содержимое памяти теряется. Запускаем новый код, и всё хорошо. Но есть ведь данные, которые живут гораздо дольше, в базах данных.
Реляционные базы тоже начались со строгой схемы. Возможно, тоже из соображений производительности. Мы обязаны указать набор таблиц, набор колонок в каждой таблице, типы колонок. Позднее появились констрейнты, чтобы жёстко связать данные не только в рамках одной таблицы, но и между таблицами. Ну вы знаете, все эти 100500 нормальных форм предназначены как раз для того, чтобы всё было жёстко и красиво.
Как добавить новую колонку в БД? На то есть ALTER TABLE. Но не всё так просто. Как минимум, для старых данных нужно предусмотреть разумное значение по умолчанию, и не всегда NULL — это хорошо. Как максимум, у нас может оказаться не один сервер БД, мы захотим выкатить изменение не только лишь для одной колонки, а всё, что накопилось с предыдущего релиза. Для автоматизации всей этой возни придумали миграции. Подумать только, их даже можно откатить, если что-то пошло не так. Но также представьте, что в момент релиза, на какой-то, пусть не сильно продолжительный, момент, все ваши базы данных вынуждены будут заниматься такой глупостью, как миграция, вместо того, чтобы честно отвечать за запросы.
Ну вот ребята, придумавшие NoSQL, точнее, в контексте данного разговора, документо-ориентированные БД, так и сказали: долой схемы и миграции, мы будем schemaless. Вместо жёстко определенного набора колонок в таких БД имеются документы, которые могут содержать произвольный набор полей. Поля идентифицируются именами. Понятно, что некоторый код, обрабатывающий документы, имеет дело с определенным набором полей. Ожидает, что у обрабатываемого документа есть, например, id и name. Но на наличие или отсутствие других полей ему совсем наплевать. И он должен быть готов напороться на документ, в котором и ожидаемых полей нет. В общем, привет, утиная типизация, всё тут абсолютно так же.
Но как же миграции? Как добавить новое поле в документ? Ну, для начала, просто добавить. Просто в новых документах, добавляемых в БД, будет новое поле. А в старых документах, которые находились в БД ранее, этого поля не будет. Можно, по примеру реляционных БД, пройтись по всем имеющимся документам и добавить к ним новое поле, его значение по умолчанию. Но это, очевидно, не эффективно.
Можно не обновлять документы вообще. Как мы помним, согласно SOLID, мы не должны ни модифицировать старый код, ни выкидывать его. Пусть старые документы обрабатываются старым кодом, а новые документы — новым кодом.
Но еще интереснее обновлять документы по мере надобности. Взяли документ из БД, когда нам он понадобился. Смотрим, а он старый, и нового поля в нем нет. Обновим документ, добавим новое поле. Запишем обновление в БД. А дальше в обработку передадим уже обновлённый документ.
Для удобства распознования старого и нового документа имеет смысл добавить версию. Версию схемы данного документа :)
Самое удивительное в том, что строгую и утиную типизацию в БД можно совмещать. Теперь, даже в рамках одной СУБД. Великолепный PostgreSQL умеет эффективно хранить JSONы в jsonb. Вот и можно всякий хлам свалить в JSON колонку, а серьёзные данные, со всякими внешними ключами, выделить в отдельные классические реляционные колонки.
А еще более удивительно то, что таблицы в PostgreSQL можно наследовать. В том самом сиплюсплюсном смысле, с добавлением полей/колонок. Получается, что можно не делать ALTER TABLE, а просто создавать новые таблицы-наследники, когда нужно расширить схему. SOLIDно.
Open closed
Данные, объекты да структуры в памяти и в БД — это еще не всё. Ещё мы любим это дело передавать по сети, в виде каких-то сообщений, удалённых вызовов и т.п. Давным давно было принято сериализовать объекты в XML. Причём, так как сериализовали строгие классы в стиле C++, то придумали схему XSD, с таким же наследованием и возможностью добавления новых элементов при наследовании только после элементов базового типа. И был такой RPC, где всё сериализовывалось в XML, а передаваемые данные описывались в XSD. И назывался он SOAP. Бойтесь протоколов со словом Simple в названии, а тем более — от Microsoft. SOAP и до сих пор жив, он является дефолтным протоколом в WCF — популярном коммуникационном фреймворке для .NET.
Что нужно сделать, чтобы добавить поле в какую-нибудь сущность, передаваемую, допустим, через SOAP? Правильно, определить новую схему, подправить код, опубликовать новую версию RPC API, обновить клиентский код, оповестить всех клиентов о появлении новой версии. Никто в трезвом уме и здравой памяти не захочет делать такие вещи слишком часто.
Вы можете заметить, что нынче у нас моден REST и, вместо громоздкого XML, повсеместно используется JSON. Хоть для JSON тоже придумали схему, слава богу, никто ею особо не пользуется. Однако, сам по себе REST — это тоже довольно жёсткая схема. Ведь у нас есть сущности, есть коллекции сущностей, всё по строго определенным адресам, со строго определенными методами доступа. Чем не схема? С теми же проблемами обновления. Новое поле, новая сущность, новая версия API, обновление клиентов и т.д.
На самом деле, даже в WCF возможна утиная типизация. Пусть сервер ожидает от клиента вызова некоторых методов с передачей в качестве параметров или результатов определённых сущностей. Пока у нас совпадает неймспейс, имена методов, имена классов сущностей, имена и типы полей сущностей, а также пропущенные поля допускают null значения, мы можем передавать серверу вовсе не те классы, которые определены в его API, а какие-то наши собственные классы, с подмножеством полей. Пока сериализация и десериализация работает — всё ок. Конечно, это не типичный, не документированный, и не рекомендуемый способ использования WCF. Никто так не делает.
Потому что делают обычно наоборот. Обычно ленятся придумывать схемы, т.е. набор минимально необходимых полей, для каждого случая использования данных. А ведь передача по сети, обработка и сохранение — это совсем разные случаи. А рисуют универсальные сущности/классы, навешивают на них магические атрибуты/аннотации и используют повсюду. Эти сущности используются для генерации WCF или REST API и заглушек. Эти же сущности используются для генерации и миграции схемы БД (да, это ORM). И вроде все счастливы.
WCF
Знаю один такой настоящий проект, где вся предметная область красиво расписана в классах. Эти классы используются в описании WCF интерфейсов и сериализуются по сети. Эти же классы используются в NHibernate и напрямую сохраняются в БД, которая, конечно же, имеет аж третью нормальную форму. Всё прекрасно, всё по учебнику, всё по официальным рекомендациям. Но, когда приходит простейший на вид запрос: добавить еще одно поле в одну из сущностей, — наступает ад. Надо поменять сами сущности; надо поменять код, выгребающий эти сущности, все его три слоя; надо поменять код WCF сервера, принимающего сущности, все его репозитории, команд басы и прочее; надо поменять код веб сервера, отображающего сущности, чтобы добавить колонку в отображаемую таблицу. И при этом не требуется ничего специального, надо просто выгрести значение оттуда, где оно есть, перенести туда, где оно отображается, и отобразить.
Какая альтернатива? Ну смотрите, нам нужно собрать, передать и отобразить набор полей. Всё. Ну так давайте представим каждую сущность каким-нибудь JSON, без жёсткой схемы, Давайте передадим и сохраним этот JSON, не вникая в детали того, что мы тут передаём и сохраняем. Давайте отобразим этот JSON как просто набор полей/колонок, произвольный набор. Всё, что нам нужно здесь — это различать типы данных в этих колонках, чтобы корректно сортировать и корректно отображать те же даты. И всё. Добавление одного поля — это небольшое изменение кода, извлекающего данные. А далее, коду, передающему данные, коду, сохраняющему данные, коду, отображающему данные, уже всё равно, этот код — универсален. Понятно, что в дальнейшем, для бизнес логики, придется выделить некоторые поля, стандартизировать их, описать в схеме. Но лишь некоторые необходимые поля. Утиная типизация.
Одни и те же данные в разное время, в разных контекстах, могут и должны выглядеть по-разному. Это нормально и естественно. Называйте это утиной типизацией. Называйте это schemaless. Не пытайтесь придумать универсальную третью нормальную форму для всего. Всё течёт, всё изменяется. Выделите нечто максимально общее, что не будет меняться, как бы не менялись сами данные и требования. Часто это будут документы или сообщения.