Об ORM

2024-01-29

Как известно, в базах данных, как правило, реляционных, у нас таблицы. С колонками. А в документо-ориентированных БД лежат документы, в формате, например, JSON. С полями. Объединённые в коллекции.

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

ООП учит нас, что программа оперирует объектами. Структурами в памяти. У которых есть какие-то методы. И какое-то состояние. И объекты могут ссылаться на другие объекты. Собственно, граф объектов в памяти — это и есть то, с чем работает ООП программа.

Получается, чтобы работать с данными в базе данных, нам нужно уметь загружать данные из БД в память, и сохранять данные из памяти в БД. В динамически типизированных языках (вроде Python, или JavaScript, или PHP) обычно сильно не заморачиваются, и представляют отдельные строки таблицы реляционной БД как универсальную структуру данных ассоциативный массив. Это dictionary в Python, object в JavaScript, array в PHP или Map в Java.

Но в статически типизированных языках работать с Map как минимум менее удобно, чем с нормальными объектами. Поэтому нам нужен ORM — Object-Relational Mapping. То есть способ получать объекты из таблицы БД и сохранять объекты в таблицу БД. К сожалению, многие реализации ORM берут на себя слишком много всего дополнительного.

Можно пойти в лоб. Сделать простейшее решение. Полностью разделить собственно объекты, с которыми мы работаем. И то, как мы их извлекаем и пишем из/в БД. Пусть объекты остаются объектами. Пусть SQL остаётся SQL, и мы продолжим его использовать. Но нам понадобятся явные узкоспецифичные методы извлечения и сохранения данных. То, что называют «репозиторий».

Таковы приёмы работы, например, с JdbcTemplate в Spring. Явный SQL. Явное преобразование колонок таблицы в поля объектов и наоборот.

class H2GetRfidTagRepository(
    private val jdbc: JdbcOperations,
) : GetRfidTagRepository {

    override fun getRfidTagByNumber(tagNumber: String): RfidTagModel? {
        val (sql, params) = buildQuery(tagNumber)                   // строим запрос с одним параметром
        val result = jdbc.query(sql, ResultSetExtractor { rs ->     // выполняем запрос
            extractResult(rs)                                       // извлекаем объект из ResultSet
        }, *params.toTypedArray())                                  // передаём параметры запроса
        return result
    }

    internal fun buildQuery(tagNumber: String): Pair<String, List<Any>> {
        val sql = """
            SELECT
                t.id AS tagId,
                t.name AS tagName,
                t.number AS tagNumber,
                t.owner_id AS tagOwnerId,
                t.vehicle_id AS tagVehicleId
            FROM rfid_tag t
            WHERE t.number = ?;
        """.trimIndent()                                // обыкновенный SQL с параметром 

        return sql to listOf(tagNumber)
    }

    internal fun extractResult(rs: ResultSet): RfidTagModel? {
        if (rs.next()) {                                // из одной (в данном случае единственной) строки результата
            return RfidTagModel(                        // создаётся объект
                id = rs.getString("tagId"),
                name = rs.getString("tagName"),
                number = rs.getString("tagNumber"),
                ownerId = rs.getString("tagOwnerId"),
                vehicleId = rs.getString("tagVehicleId")
            )
        }
        return null
    }

}
class H2CreateChargingSessionRepository(
    private val jdbc: JdbcOperations,
) : CreateChargingSessionRepository {

    override fun createNewChargingSession(connectorId: String, rfidTagId: String): String {
        val id = UUID.randomUUID().toString()                           // уникальный случайный ID новой записи
        val (sql, params) = buildQuery(id, connectorId, rfidTagId)      // строим запрос с тремя параметрами
        jdbc.update(sql, *params.toTypedArray())                        // выполняем запрос
        return id
    }

    internal fun buildQuery(id: String, connectorId: String, rfidTagId: String): Pair<String, List<Any>> {
        val sql = """
            INSERT INTO charging_session (id, connector_id, rfid_tag_id)
            VALUES (?, ?, ?);
        """.trimIndent()                    // обыкновенный SQL с параметрами

        return sql to listOf(id, connectorId, rfidTagId)
    }

}

В принципе, ничего плохого тут нет. Кроме того, что репозитории и SQL запросы существуют явно. И нет красивого способа прозрачно загрузить связанные объекты из БД по мере необходимости. Загрузили, обработали, выгрузили. Если в процессе обработки нужно загрузить что-то ещё, нужно загрузить это что-то явно, и явно связать с уже имеющимися объектами. Ничего плохого, это прекрасно ложится на цикл работы веб-приложений. Всё равно для обработки пользовательского запроса нужно загрузить, обработать и сохранить данные.

Но под ORM обычно понимают несколько большее. Типичный ORM делает ещё целую кучу вещей:

  • преобразование строк-колонок в объекты-поля
  • преобразование объектов-полей в строки-колонки (собственно, ORM)
  • безопасное построение SQL запросов (через какое-то API)
  • сокрытие SQL, получение объектов через API ORM
  • прозрачное получение зависимых объектов и данных
  • сокрытие персистентности объектов (вы, пользуясь полученными из БД объектами, можете не знать, что они хранились в БД и можете не контролировать момент, когда их надо сохранить в БД)
  • управление кэшем объектов в памяти (следствие сокрытия персистентности)
  • управление жизненным циклом объекта (следствие сокрытия персистентности, это больше не ваши объекты, их контролирует ORM)
  • контроль схемы БД по форме описанных объектов (вы можете не контролировать таблицы в БД, ORM сделает это за вас)
  • создание классов объектов из схемы БД (противоположное предыдущему)

Лично у меня всё, что идёт дальше первых трёх пунктов в этом списке, вызывает большие опасения. Неконтролируемый кэш? Неконтролируемые объекты в памяти? Я не знаю в точности, когда мои изменения будут сохранены в БД? Как это вообще?

Насколько сильно ORM скрывает свою магию сильно зависит от API. Скажем, давным-давно популярным паттерном было ActiveRecord. Единственно популярный способ работы с БД в Ruby on Rails. В ActiveRecord все операции работы с данными, включая метод save(), это методы самого класса, представляющего таблицу. За это ActiveRecord и критикуют. Мол, объекты с данными явно выпячивают, что они объекты с персистентными данными.

user = User.create(name: "David", occupation: "Code Artist")

users = User.where(name: 'David', occupation: 'Code Artist').order(created_at: :desc)

user = User.find_by(name: 'David')
user.name = 'Dave'
user.save

В мире Java придумали свой API для ORM — JPA. Java (или Jakarta) Persistence API. Самая популярная его реализация: Hibernate. А ещё есть Spring Data JPA, обёртка над тем же Hibernate.

Получение данных в JPA начинается с репозитория. Который вроде как всего лишь интерфейс. А его реализация генерируется на лету исходя из известной схемы данных (да, Hibernate анализирует ваши таблицы, и даже может сам их создавать) и описанных сущностей.

@NoRepositoryBean
interface MyBaseRepository<T, ID> extends Repository<T, ID> {

    Optional<T> findById(ID id);

    <S extends T> S save(S entity);

}

interface UserRepository extends MyBaseRepository<User, Long> {

    User findByEmailAddress(EmailAddress emailAddress);

}

А сами данные — это обычные java beans, помеченные специальными аннотациями, чтобы указать, кто здесь первичный ключ, а где внешние ключи, и вообще, какие тут имеются связи между таблицами/объектами.

@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String firstName;
    private String lastName;

    //...
}

Как получать данные, более-менее понятно. Есть хитрые методы репозитория. Есть магия превращения хитрого названия метода в запрос к БД. При большом желании и крайней необходимости можно и SQL свой написать. Только это будет не SQL, а специальный язык запросов Hibernate. Там вместо имён таблиц используются имена классов сущностей. И условия выборки и джойнов описываются в более «объектном» стиле. Мне такое не нравится. Такой запрос не выполнишь так просто в консоли БД, потому что это не SQL, он лишь очень похож на SQL.

Как добавлять данные, тоже более-менее понятно. Мы создаём объект сущности как-то минуя репозиторий. Это же всего лишь приправленный магией обычный java bean. Можно использовать new. А потом вызываем метод save() репозитория.

А как обновлять данные? И вот тут вылезает необходимость знать кучу подкапотной магии JPA и Hibernate. Оказывается объекты-сущности помнят, с какой именно строчкой таблицы в БД они связаны (ну, по первичному ключу). Объект, полученный из репозитория, и объект, созданный через new, различаются. Второй знает, что он не соответствует ни одной строке в таблице, он новый. И только вызов save() у репозитория выдаст ему первичный ключ и сделает объект managed, теперь hibernate заботится о его жизненном цикле.

Так вот, update — это просто вызов сеттера у managed сущности. Hibernate запомнит, что сущность изменилась. И будет сохранять её изменившееся состояние. Когда? Когда будет завершаться транзакция. Да, у нас ещё появляются транзакции. В простейшем случае нам не нужно особо заботиться о них. Тот же Spring MVC будет обрамлять в транзакцию обработку каждого HTTP запроса. Но если мы хотим точно контролировать, когда изменённые данные попадут в БД, нам нужно явно позаботиться о транзакциях.

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

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

В одном старом проекте для Андроида я попробовал другой подход. Вся бизнес-логика, все сущности и операции над ними, описаны интерфейсами. Каждый метод интерфейса — это атомарное и консистентное изменение. Для изменений, которые невозможно или не хочется выразить одним вызовом метода, есть Editor, который по сути является Unit of work и Builder. То есть несколько изменений объединяются в транзакцию, которая должна быть завершена явно.

interface Entity<EditorType extends Entity.Editor> {

    EditorType edit();

    public interface Editor {

        void commit();

    }

}

public interface Action extends Entity<Action.Editor> {

    Set<Folder> getFolders();

    String getHead();

    String getBody();

    public interface Editor extends Entity.Editor {

        Editor setHead(String head);

        Editor setBody(String body);

    }

}

И есть реализация всех этих интерфейсов, где все данные хранятся в SQLLite базе данных, как оно принято в Андроиде. Каждый геттер делает одну транзакцию. Editor.commit() делает свою транзакцию.

class SQLiteAction implements Action {

    transient SQLiteDatabase db;
    final long id;

    String head;
    String body;
    SQLiteFolderSet folders;

    //...

    public Set<Folder> getFolders() {
        assert(this.id != 0);
        checkDb(this.db);
        this.db.beginTransaction();
        try {
            Cursor cursor = ActionDao.selectFolders(this.db, this.id);
            List<SQLiteFolder> result = new ArrayList<SQLiteFolder>();
            while (cursor.moveToNext()) {
                result.add(FolderDao.getFolder(this.model, cursor));
            }
            Collections.sort(result, new SQLiteFolderComparator(this.model));
            this.folders.setFolders(result);
            cursor.close();
            this.db.setTransactionSuccessful();
        } finally {
            this.db.endTransaction();
        }
        return this.folders;
    }

    //...

    private class SQLiteActionEditor implements Action.Editor {

        private String head = SQLiteAction.this.head;
        private String body = SQLiteAction.this.body;

        //...

        public void commit() {
            checkDb(db);
            db.beginTransaction();
            try {
                ActionDao.updateAction(db, SQLiteAction.this, this.head, this.body);
                db.setTransactionSuccessful();
                SQLiteAction.this.head = this.head;
                SQLiteAction.this.body = this.body;
            } finally {
                db.endTransaction();
            }
        }

    }

}

Очень хитрыми получились List и Set. Они же модифицируемые (с немутабельными коллекциями получилось бы, пожалуй, проще, тот же Editor для коллекций добавить). И добавление или удаление элементов в/из списка тоже немедленно отражается на данных в БД.

class SQLiteFolderSet extends SQLiteModelEntity implements Set<Folder> {

    List<SQLiteFolder> folders;

    //...

    public boolean add(Folder folder) {
        if (!(folder instanceof SQLiteFolder)) {
            throw new UnsupportedOperationException("cannot add not-SQLite folder");
        }
        SQLiteFolder sqlFolder = (SQLiteFolder)folder;
        checkDb(db);
        db.beginTransaction();
        try {
            addFolder(sqlFolder);
            updateOrder();
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
        return true;
    }

    //...

}

В итоге происходит работа с объектами через интерфейсы. Немного нетривиально изначальное получение объектов. Но дальше все операции определены и ограничены интерфейсами. Да, на каждый чих происходит обращение к БД (и сохранение изменений, это привязано к жизненному циклу активностей, то есть пользовательских экранов в Андроиде). Но таких чихов на каждое действие пользователя — лишь единицы. В общем-то, как и в вебе. И в целом производительность не страдает, как могло бы показаться.

// разные методы разных Activity в иерархии наследования...

public static Model openModel(Context context) {
    return new SQLiteModel(context, DATABASE_NAME);
}

protected void onResume() {
    //...
    path = intent.getStringExtra(EXTRA_FOLDER_PATH);
    folder = getModel().getFolder(new SimplePath(path));
    //...
}

void getActionFromIntent() {
    //...
    int position = intent.getIntExtra(EXTRA_ACTION_POSITION, 0);
    setAction(getFolder().getActions().get(position));
    //...
}

protected void onOkClick() {
    EditText body = (EditText)findViewById(R.id.action_body);
    String actionBody = body.getText().toString();
    String actionHead = ActionHelper.getHeadFromBody(actionBody);
    getAction().edit().setHead(actionHead).setBody(actionBody).commit();
    finish();
}

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

Но мне всё же кажется, что это и есть правильный ORM. Когда действительно можно работать с объектами как с объектами. А их реализация уже будет ответственна за то, чтобы правильно сохранить нужное в БД. Без протекания абстракций в виде необходимости управлять транзакциями или учитывать то, были ли эти объекты получены из БД или другим путём.