Об 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. Когда действительно можно работать с объектами как с объектами. А их реализация уже будет ответственна за то, чтобы правильно сохранить нужное в БД. Без протекания абстракций в виде необходимости управлять транзакциями или учитывать то, были ли эти объекты получены из БД или другим путём.