Перейти до вмісту

ORM

бібліотека, що відображає таблиці бази даних на об'єкти коду

  • Jigsaw puzzle, puzzle pieces, solved
    Фото: josemiguels / Pixabay
  • Abc, dictionary, words
    Фото: PDPics / Pixabay

Застосуйте поняття до рішення

Застосуйте «Що таке ORM?» до конкретного рішення. Ми можемо пов'язати поняття із системою, яку ви створюєте або купуєте. Та частина, якої користувач не бачить: де зберігаються дані, які діють правила і як інші системи можуть із нею взаємодіяти.

Переглянути наші послуги Зв'язатися з нами

Перевірте припущення за терміном

Використайте пояснення та пов'язані роботи, щоб сформулювати запитання для проєкту. Ми можемо перевірити припущення разом до вибору інструмента. Спроєктувала й підтримувала 6 пайплайнів ETL/ELT — 6 пайплайнів ETL на трьох рушіях баз даних

Спроєктувала й підтримувала 6 пайплайнів ETL/ELT Зв'язатися з нами

Що це таке

ORM, скорочення від object-relational mapper, — це бібліотека, яка пов’язує таблиці реляційної бази даних із класами в коді застосунку, а рядки цих таблиць — з об’єктами. Код просить автора й отримує об’єкт Author, змінює один з його атрибутів і зберігає його, а ORM пише інструкції SELECT, INSERT і UPDATE, які виконують цю роботу. Моделі Django та ORM-частина SQLAlchemy, обидві написані на Python, — два відомі приклади, і саме на них спирається ця стаття.

Відображення потрібне тому, що дві сторони організовують дані по-різному. Мартін Фаулер прямо каже про це в описі патерну Data Mapper: об’єкти й реляційні бази даних структурують дані кожен по-своєму, а частини об’єктної моделі, як-от колекції та успадкування, не мають відповідника в реляційній базі. Перенесення даних між ними стає окремою задачею, і ORM — готова відповідь на неї.

Складники цієї відповіді отримали назви в книзі Фаулера Patterns of Enterprise Application Architecture 2003 року, а каталог патернів із неї вільно доступний онлайн. Два патерни описують загальну форму. Active Record — це об’єкт, який обгортає один рядок таблиці, сам відповідає за доступ до бази й несе доменну логіку для цих даних; за філософією дизайну Django, моделі там наслідують саме цей патерн. Data Mapper — окремий шар, який переносить дані між об’єктами та базою і тримає їх незалежними одне від одного, тож об’єктам навіть не треба знати, що база взагалі існує. Навколо них є кілька менших патернів. Identity Map стежить, щоб кожен об’єкт завантажувався лише один раз. Unit of Work веде список об’єктів, які змінила певна частина роботи, і записує зміни разом. Lazy Load — це об’єкт, що містить лише частину даних, потрібних коду, але знає, як отримати решту. Документація SQLAlchemy прямо називає два з них: Session тримає завантажені об’єкти в identity map, де на кожен первинний ключ припадає лише один об’єкт, а спосіб, яким вона збирає зміни та скидає їх у базу через flush, — це патерн unit of work.

Чому це важливо

Перша причина — скільки коду ORM заощаджує. У есеї ORM Hate 2012 року Фаулер називає задачу об’єктно-реляційного відображення складною: йдеться про те, щоб тримати синхронними два дуже різні представлення тих самих даних, і зміну з одного боку треба перенести на інший. За його оцінкою, ORM розв’язує приблизно від 80 до 90% задач відображення, і він вважає, що фреймворк, який позбавляє команду 80% цієї роботи, вартий того, навіть якщо на цьому він і зупиняється. Кожна таблиця, яку команді не доводиться відображати вручну, — це на одне місце менше, де стовпець може потрапити не в той атрибут.

Друга причина — узгодженість усередині запущеної програми. З identity map два запити, які знаходять той самий рядок, повертають той самий об’єкт, тож зміна, зроблена через одне посилання, видна через інше; документація SQLAlchemy показує це, двічі завантажуючи той самий первинний ключ і отримуючи той самий об’єкт. З unit of work ORM збирає вставлення, оновлення й видалення, що очікують, і, за списком можливостей SQLAlchemy, скидає їх у базу одним пакетом, тож коду застосунку не треба самому пам’ятати, яких об’єктів він торкався. Django свідомо робить протилежний вибір: його філософія дизайну вимагає явного виклику save() замість того, щоб фреймворк зберігав дані непомітно, і так надсилає якомога менше SQL-інструкцій.

Третя причина — те, де проходить межа. Фаулер описує добру ORM як таку, що бере на себе рутину, але залишає люк, через який можна спуститися до SQL, коли це потрібно, і прямо каже, що кожен, хто будує на реляційній базі даних, мусить знати, як така база працює. Філософія дизайну Django каже те саме з боку фреймворку: API для роботи з базою має розуміти, що воно лише скорочений шлях, а не обов’язково вся відповідь, і фреймворк має робити власний SQL простим у написанні — чи то цілі інструкції, чи лише власна умова WHERE. ORM заслуговує на своє місце в рутинній частині доступу до даних; решта — те, на що команда витрачає своє знання SQL.

Як це виглядає на практиці

Ціна, яку ORM приховує найчастіше, — кількість запитів, які вона надсилає. У SQLAlchemy 2.1 зв’язки за замовчуванням завантажуються ліниво: пов’язані об’єкти отримуються тоді, коли код уперше звертається до атрибута. Документація прямо називає наслідок: для N завантажених об’єктів звернення до їхніх ліниво завантажуваних атрибутів дає N+1 інструкцій SELECT. Приклад нижче припускає, що класи Author і Book уже відображено зі зв’язком один-до-багатьох Author.books, і проходить авторами трьома способами.

from sqlalchemy import select
from sqlalchemy.orm import raiseload, selectinload

# За замовчуванням: один SELECT для авторів, а потім ще по одному
# для кожного автора, чиї книжки зачіпає цикл, разом N+1 інструкцій.
authors = session.scalars(select(Author)).all()
for author in authors:
    print(author.name, len(author.books))

# select IN: другий SELECT отримує книжки всіх авторів одразу,
# з їхніми первинними ключами в умові IN.
stmt = select(Author).options(selectinload(Author.books))
for author in session.scalars(stmt).all():
    print(author.name, len(author.books))

# raise: звернення до author.books тепер спричиняє помилку,
# а не непомітно надсилає ще один запит.
stmt = select(Author).options(raiseload(Author.books))

Для колекцій один-до-багатьох і багато-до-багатьох документація називає selectinload() загалом найкращою стратегією. joinedload(), яку вона називає найуніверсальнішою, додає JOIN до початкової інструкції, за замовчуванням LEFT OUTER JOIN, тож пов’язані рядки приходять в одному наборі результатів. raiseload() існує, щоб захищати від небажаних завантажень, а її форма з символом підстановки, raiseload("*"), поширює цей захист і на інші зв’язки, зокрема на зв’язки об’єктів, які інструкція таки завантажує.

Django 6.1 дає тому самому вибору власну назву: fetch modes, режими отримання. Коли код звертається до поля, якого початковий запит не завантажив, наприклад до зовнішнього ключа, зв’язку один-до-одного або поля, пропущеного через defer() чи only(), режим за замовчуванням FETCH_ONE отримує його лише для цього одного екземпляра, і документація сама називає наслідок проблемою N+1 запитів. FETCH_PEERS, новий у 6.1, отримує відсутнє поле для цього екземпляра й для всіх інших екземплярів із того самого QuerySet, виходячи з того, що коду, якому поле потрібне для одного рядка, зазвичай потрібне воно й для решти. FETCH_RAISE натомість спричиняє FieldFetchBlocked. Режим задають для окремого QuerySet, як у Book.objects.fetch_mode(models.FETCH_PEERS), або як типовий для моделі через власний менеджер, і Django копіює його на пов’язані об’єкти, які отримує. Там, де FETCH_PEERS не підходить або недостатньо ефективний, посібник з оптимізації радить select_related() і prefetch_related().

Власні сторінки компанії проводять цю межу ближче до бази даних, ніж більшість. Сторінка технологій розглядає SQL як повноцінну мову, а не те, що генерує ORM, бо саме там здебільшого лежить робота з продуктивністю, заради якої компанію запрошують. Компанія довела цю думку до кінця й спроєктувала функційний рівень даних PostgreSQL: кожна операція застосунку — це одна з 1 275 збережених функцій у 34 схемах, і прямого доступу до таблиць із Go немає взагалі. Роль, від імені якої API входить у базу, має привілей EXECUTE для цих функцій і USAGE для їхніх схем — і більше нічого, тож напряму звернутися до таблиці вона не може. Сторінка відверто називає ціну: нове поле означає зміну і таблиці, і функції, а не ще один рядок у запиті.

Де виникають проблеми

Цикл надсилає один запит на кожен рядок. Описаний вище патерн N+1 здається нешкідливим на невеликій базі розробника, а потім сповільнює сторінку пропорційно до кількості рядків. Визначте спосіб завантаження для кожного важливого шляху в коді: selectinload() або joinedload() у SQLAlchemy, FETCH_PEERS, select_related() або prefetch_related() у Django. Потім зробіть так, щоб незапланований випадок падав гучно: raiseload() у SQLAlchemy або FETCH_RAISE, який документація Django описує як спосіб запобігти ненавмисним запитам у критичному до продуктивності коді.

Ніхто не читає SQL, який пише ORM. ORM пише SQL, якого ніхто в команді не обирав, і цей SQL залишається невидимим, доки сторінка не почне гальмувати. Посібник Django з оптимізації починається саме з цього: з’ясуйте, які запити виконує сторінка і скільки вони коштують, скористайтеся QuerySet.explain(), щоб побачити, як база виконує певний QuerySet, і прочитайте django.db.connection.queries, щоб побачити, що Django пише від імені коду. Зробіть читання цього SQL сталою частиною рев’ю кожної важливої сторінки чи задачі, а повільні інструкції проганяйте через план запиту самої бази.

Робота, якій місце в базі, виконується в Python. Завантажувати рядки лише для того, щоб відфільтрувати їх, порівняти два їхні поля чи підсумувати, означає переносити кожен рядок через з’єднання, щоб у коді застосунку зробити те, що база могла б зробити там, де дані вже лежать. Посібник Django перелічує альтернативи: filter() і exclude() для фільтрування в базі, вирази F() для порівняння полів у межах однієї моделі та annotate() для агрегування в базі. Записування підпорядковується тому самому правилу: bulk_create() і bulk_update() замість окремого save() для кожного об’єкта, після перевірки застережень, які документація наводить для обох. Для дуже великого набору результатів QuerySet тримає все, що прочитав, і може займати багато пам’яті, і тоді може допомогти iterator().

Останню частину проштовхують крізь ORM. Власна оцінка Фаулера залишає від 10 до 20% задач відображення, і ця остання частина, за його словами, завжди потребує ретельної роботи. Якщо гнути ORM, доки вона не видасть певну інструкцію, ця робота ховається в згенерованому SQL, якого ніхто не обирав. Пишіть такі інструкції на SQL і переглядайте їх, як будь-який інший код. Посібник Django називає щаблі вниз: вираз RawSQL додає до запиту явний SQL, менш переносний, але потужніший, а далі команда може писати власний SQL, щоб отримувати дані або заповнювати моделі. Залишайте ORM для рутинних записів, де вона заощаджує найбільше й коштує найменше.

Що почитати далі

Словник

Обирайте за фактичними умовами

Опишіть робочий процес, наявні інтерфейси та користувачів, яким потрібна зміна. Ми можемо визначити перевірюваний результат і передачу, придатну для підтримки командою.

Дивитися, скільки це коштує Зв'язатися з нами

Зробіть термінологію практичною

Запитайте, як «Що таке ORM?» стосується вашої організації. Конкретного прикладу з поточної системи достатньо для початку розмови.

Переглянути наші послуги
welcome@engineer.company +45 50 10 61 84 Копенгаген, Данія
QR-код сторінки «ORM»
Візьміть цю сторінку із собоюhttps://engineer.company/uk/glossary/orm/