Модели или типы

Illustration of a person standing in front of two bathroom doors labeled 'M' and 'T', appearing to be in thought or making a decision. Illustration of a person standing in front of two bathroom doors labeled 'M' and 'T', appearing to be in thought or making a decision.

Главное преступление старой школы веб-разработки – их подмена концепта типа концептом модели. Я ссылаюсь на старую школу (вместо ссылки на "динамическую типизацию") из-за java. Они всё также продолжают продвигать совершенно возмутительную идею "ORM" и сегодня. Но обо всё по-порядку.

Что такое "Модель"? Декларативное описание чего-то, сохраняемого в БД. По-сути, его типа. Все имплементации SQL печально известны своими непродуманными и ограниченными типами столбцов. Boolean представляется как TINYINT(1), и возвращается консюмеру в виде 1 или 0, поскольку драйвер не имеет понятия, что это значение должно было быть Boolean. Нередко даты возвращаются как String даже при наличии нативного Date. JSON поля крайне полезны, тогда как их поддержка не выдерживает критики. Так что необходимость "парсить из" и "форматировать в" SQL можно принять как неизбежность. Вспомним также проблему SQL инъекцией.

Вывод: оставаться на чистом SQL действительно сложно. Вам, вероятно понадобится дополнительный слой. И тут наша история начинается.

Что не так с Моделью? Первое – у вас может быть более одной базы данных. Подождите... второй ORM?! Ключевые структуры данных описанные дважды. В различном синтаксисе. Удачного полёта!

Второе – что насчёт фронтенда? Вам придётся описывать одну структуру дважды – для сервера и для клиента. На разных языках. Фуллстек JS – говорите? Ну, тогда просто в различном синтаксисе.

Хороший код декларативен, так что типы (со всей обвязкой) легко могут занимать 30% всего объёма. И грядущие Зависимые Типы лишь увеличат этот процент. Как долго вы сможете маскировать разложение кода?

Возьмём любую ORM, да тот же Bookshelf.

let User = bookshelf.Model.extend({
  tableName: 'users',
  posts: function() {
    return this.hasMany(Posts);
  }
})

Мы описывает тип User на языке "моделей". И мы сильно, очень сильно, возмутительно сильно ограничены.

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

let T = require("tcomb")

// Основные поля (видимы для всех ролей, кроме "visitor")
let PublicUser = T.struct({
  id: Id,
  role: UserRole,
  username: T.String,
  fullname: T.String,
  blocked: T.Boolean,
  rating: T.Number,
})

// Защищённые поля (видимы для платных юзеров и владельца)
let ProtectedUser = PublicUser.extend({
  phone: T.maybe(T.String),
  email: T.maybe(T.String),
})

// Полная "модель" (все поля БД) (видимы для владельцев / админов)
let User = ProtectedUser.extend({
  balance: T.Number,
  createdOn: T.Date,
  editedOn: T.Date,
  paidUntil: T.maybe(T.Date),
})

// Тип формы (создание / редактирование)
let UserForm = T.struct({
  username: T.String,
  fullname: T.String,
  phone: T.maybe(T.String),
  email: T.maybe(T.String),
})

Смогли бы вы описать это на какой-либо ORM? Никак нет.

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

The question of types is tightly coupled with validation. Non-dependent typesystems can't express repeatPassword == password and similar edge cases. A few of them. Validation languages follow type languages closely. What they add? Customizable messages... Not closely enough to satisfy the duplication of concerns. Those messages can easily be built on top of types (if types are first-class).

Вопрос типизации тесно связан с валидацией. Не-зависимые системы типов не могут описать repeatPassword == password и подобные граничные случаи. Их немного. Правила валидации следуют правилам типизации с поразительной точностью. И добавляют... настраиваемые сообщения об ошибках. Не тянет на оправдание дупликации полномочий. И сообщения легко реализовать поверх типов (первоклассных типов).

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

Что есть реальность? Типы. Они есть у всего. Составное поле "адреса" не отличается принципиально от "пользователя". У них одна и та же структура. Их следует описывать на одном языке.

-- Измерение МОДЕЛЕЙ --
user       -- Model
  address  -- JSONField
    street -- StringField

street = user.address.street -- String

-- Леди и Джентльмены! Позвольте мне представить вам нашего особого гостя Василия Сложного!

----------------------------------

-- Измерение ТИПОВ --
user       -- Struct
  address  -- Struct
    street -- String

street = user.address.street -- String

-- Скучно. Не вдохновляет. Слишком просто.

Тебе нужны типы, а не модели, мой друг.

Но я не могу использовать чистый SQL?!

Just don't use ORMs. There are so many alternatives: NoSQL, DAO, query builders with custom parsing. Everything that does not require layer-specific datastructures is better. Currying and functional programming will help you, as always.

Просто не используй ORM. Есть множество альтернатив: NoSQL, DAO, билдеры запросов с кастомным парсингом. Всё, что не пытается подменить систему типов, окажется лучшим выбором. Каррирование и функциональное программирование помогут, как всегда.

// SQL + Knex
let users = map(parseAs(User))(await db("users").select("*"))

// RethinkDB + RethinkDB pool
let users = conn.run(await table("users"))

Избегай вещей, отравляющих разум. Некоторые плохие идеи очень липкие. OOP, REST, ORM... Субъективное восприятие важности чего-либо растёт вместе с вложенным (потраченным) временем.

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

Я могу бы продолжать техническую критику вроде этой или этой но это лишь разбавит основную идею. Я использовал DoctrinedORM, PropelORM, SQLAlchemy, Peewee, Bookshelf, и т.д. Я даже написал пару собственных на Python и PHP. Конечные ощущения всегда оказывались идентичны.

Я заявляю, что это и есть преступление бекенд разработчиков 2000-ных. Они не имели адекватных NoSQL решений. Они не были в курсе альтернатив. Им было нужно "просто сделать свою работу". Какие-то оправдания уместны.

Затем они уверовали в собственный хак. Что их костыли над недоработками SQL решений полезны сами-по-себе. Вместо давления с целью улучшить драйверы SQL (убирая обоснования ORM по-одному) они выбрали прогиб. И заразили тысячи умов.

Болезнь распространяется. Уже есть ORM-ы ODM-мы для Mongo. Для RethinkDB. Которые являются ни чем иным как ужасной заменой систем типизации. Системой типизации для бедных.

А (в самом конце) стоят люди, переносящие паттерн "Unit Of Work" в веб. Неконтролируемое, имплицитное кеширование каждого запроса в неинтерактивной среде. Cоциопатия. Видение мира через призму "моделей" очевидно опасно.

Есть и другие моменты, заслуживающие упоминания. Концепция объединений (JOIN). SQL объединения склеивают данные вместе, порождая новые препятствия для типизации.

SELECT * FROM users
INNER JOIN comments
ON comments.user_id = users.id

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

Вы не захотите описывать специальный тип только для JOIN? А придётся, ведь ваши типы не покрывают склеенного случая.

А теперь посмотрите как объединения реализованы в RethinkDB. Результирующая последовательность содержит документы с left и right частями, к которым вы можете применить zip (если хотите) или обработать иным образом. Вы знаете, какие поля приходят из какой таблицы.

{
  "left": {
    "id": "543ad9c8-1744-4001-bb5e-450b2565d02c",
    "text": "You can fool some of the people all of the time, and all of the people some of the time, but you can not fool all of the people all of the time.",
    "userId": "064058b6-cea9-4117-b92d-c911027a725a"
  },
  "right": {
    "id": "064058b6-cea9-4117-b92d-c911027a725a",
    "fullname": "Abraham Lincoln"
  }
}

Я не смогу объяснить достаточно эмоционально насколько умнее этот вариант. Насколько удобнее и дружественнее к пользователю. Люди, объявляющие NoSQL движение "модным" имеют проблемы со зрением.

И теперь к вопросу REST. Нащупывая контекст программной катастрофы двухтысячных, мы никак можем пропустить этого идола.

Рассмотрим следующие способы создания нового User:

POST /users     {username: "jack", ...} -- body не должно иметь id (автогенерация)
PUT  /users/:id {username: "jack", ...} -- body не должно иметь id (конфликт)

Посылаемые в body данные не должны иметь id. Так что вам придётся выделить ещё один тип на это (см. UserForm выше).

Хотя, технически, вы можете ограничиться только User и отказаться от UserForm, это драматически усложнит вещи. Вам придётся заниматься менеджментом всех полей вручную. Императивно.

  1. Защититься от подмены id
  2. Защититься от увеличения баланса
  3. Защититься от подмены роли
  4. ...

И так далее, и тому подобное. Тогда как с адекватной типизацией это просто:

router.post("/api/users/role/:role",
  KoaValidate({
    "params.role": UserRole,
    "body": UserForm,
  }),
  function* (next) {
    ...
  }
)

Декларативно. Просто. Без дупликации. А теперь идите и сделайте это на Knex, Bookshelf, Joi и на какой-то там фронтенд библиотеке валидации.

Что если вы имели бы простейший случай, когда одного типа было бы достаточно? Очевидный способ:

PUT /users {id: "1", username: "jack", ...}

Но это не по-ресту! Рой Филдинг лично запретил это. А здесь, в Ай-Ти, мы очень чувствительны ко мнениям гуру. А теперь серьёзно – кто имеет право диктовать подобные детали? Кто перетаскивает вещи, специфические для приложения, даже не в библиотеку. В протокол! И делает это постоянно.

Кто эти люди? Что они делают в индустрии? С индустрией? Но это тема для другой серии.

Я правда надеюсь, что GraphQL подорвёт позиции REST в достаточной мере. Со всеми своими недостатками, команда Facebook заслуживает уважениях хотя бы за свою храбрость. Они пытаются "уничтожить" три священных грааля Веба: CSS, шаблоны и REST. Безпрецедентная попытка. И не могу сказать, что буду сожалеть о последних двух.

Заключение

Так какие у нас перспектива? Можем ли мы ожидать, что это безумие с моделями однажды пройдёт?

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

Есть смелые индивидуальные попытки заслуживающие внимания. Без хуков (и другой помощи со стороны компилятора) они вынуждены воспроизводить функционал парсера. Но это (уже) работает. И это очень круто.


Обновление 1

@GiulioCanti подсказывает весьма интересные утилиты Flow, которые потенциально могут помочь объединить статическую и динамическую реальности.

Детальнее можно посмотреть тут и тут.


Обновление 2

Люди спрашивают о "печальности" JOI? Простите, но как ещё назвать официально бекенд-only библиотеку валидации? Последний раз, когда я пробовал, она увеличила мой бандл на 1.2 Мб. RIP.