Місце ORM у загальній архітектурі застосунку
Колись на співбесіді мене спитали: «А як ти вчишся?». І нічого іншого, як відповісти «На практиці», я не придумав. У цій статті хочу поділитись певним своїм практичним досвідом і сподіваюсь прочитати про схожий досвід від колег.
tldr
Ця стаття — спроба порефлексувати про місце ORM у загальній архітектурі програмного застосунку. Стандартний варіант — інжектити екземпляр об’єкта, сконструйованого ORM-класом, у бізнес-логіку та оперувати ним. Альтернатива — зробити жорстке розділення рівня сховища та об’єкта сутності через абстракцію — Datagateway. Якшо ти на реальному продакшн-проєкті з таким заморочувався — поділись досвідом у коментарях.
Два підходи до архітектури
Отже, уявімо що ми починаємо будувати пет-проєкт. Застосунок для реєстру безпритульних собак. Що в нас є? Два епіка, 75 юзерсторі, пʼять розпланованих спринтів, скрам-мастер і гора ідей, концепцій, гіпотез усіх кольорів... А фактично ми або починаємо малювати таблиці бази даних з полями, або розписувати, хто які дії може виконати в застосунку. І вже далі від цього продовжуємо розробку. І зрештою отримуємо два різні підходи:
- схема даних диктує, як ми зможемо прописувати логіку;
- логіка диктує, як і що ми можемо зберегти.
Звісно, життя не двокольорове, воно є градієнтом сірого. Але від фокусу (концентрації) на одному або іншому підході не втекти. Думаю, всі знають про MVС, де model йде першою, тому з неї і починають. Не знаю, чому зараз учать в університетах, але я пам’ятаю час, коли для MVС-підходу взагалі не було альтернатив. І тому не було місця і для роздумів про ORM.
- Створити базу.
- Через ORM отримати доступ до даних у зручному вигляді.
- Проманіпулювати даними в контролері.
- Показати результат у представленні.
- ???
- Profit.
То що там з альтернативами у вашому
- Описати об’єкти.
- Описати, як ними маніпулювати, щоб отримати користь.
- Підключити джерела отримання / відправлення даних об’єктів.
- ???
- Profit.
Якшо там profit і там profit, то яка різниця? У першому випадку в нас ORM відповідає за трансфер об’єкту в дані, а в іншому — datagateway. Хоча що одне абстракція, що друге абстракція, і взагалі ми не бул б інженерами, якби не намагалися все переускладнити.
Що за ORM, про що взагалі тут мовиться
ORM — object relational mapper. Приблуда, яку придумали ліниві розробники, які не хотіли вчити SQL і писати: Insert into dogs (status, size, color, lat, lon) values (‘wild’, ‘small’, ’чорне’, 1, 1)
а хотіли просто зберегти безпритульного собачку в базу: new Dog({ status:‘wild’, size: 'small',color:’чорне’, lat:1, lon:1 }).save()
І в цей момент у нас з’явився дуплет: бібліотека для роботи з базою даних і бібліотека для зручної роботи з базою даних. Наприклад, Mongo Driver та Mongoose (для SQL теж щось знайдеться в гуглі). І тепер джуна можна ганяти не однією, а двома бібліотеками на співбесіді. Хочу зазначити, що в цій статті я не засуджую використання ORM: той факт, що вони існують, масово використовуються та розвиваються, означає, що їхнє існування необхідне. Питання: як їх застосовувати і коли? І якщо свою розробку вести від даних, структура таблиці диктує, які поля може мати об’єкт собачки. То екземпляр ORM проникає в усі файли, де йдеться про собачок. І ніяких проблем у цьому нема. Хочеш внести зміну — онови базу й користуйся новим об’єктом. А можна зробити тролейбус із хліба?
Datagatway
А якщо я веду розробку від бізнес-логіки? То мене не цікавить, як саме імплементовано рівень сховища (persistence layer). Я ще не планував його, я не знаю, які мені треба таблиці. Я знаю, що в мене є собачки, телеграм і бажання їх реєструвати та показувати в результаті пошукових запитів. Отже, мені треба абстрагуватися від сховища, ввівши сутність, що буде робити лише затребувані дії зі сховищем у бізнес-логіці, інкапсулювавши в собі реальну реалізацію цих дій. І що важливо: не буде давати доступу до всього, що логічно пов’язано із цим сховищем. Описано складно, а в коді це може виглядати так:
Class Storage{ getDogs():Dog[]{ .... } saveDog(dog:Dog):Dog{....} updateDog(dog:Dog):Dog{....} deleteDog(dog:Dog):void{.....} }
Class Dog{ constructor({ status?:PET_STATE, size:PET_SIZE, color:PET_COLOR, location:[number,number] }) }
Dog — об’єкт представляє сутність собачки, його структура не залежить від структури таблиці. Ми описали його незалежно. Усе, що стосується того, як і де він може зберігатися, описано в Storage. І саме це є тим жорстким розділенням, про яке я згадував на самому початку. З одного боку можна сказати: «Пффф, раніше я писав dog.save()
, а тепер буду storage.saveDog(dog)
. Навіщось додав ще один клас для дублювання CRUD». Але зупинись і подумай, що ти втрачаєш і що набуваєш, коли в об’єктів пропадає .save()
? Так взагалі можна щось напрограмувати? У рамках скоупу пет-проєкту практика показує, що можна. А на продакшні? Та хто ж тобі дозволить. Ти мені про це розкажи, пробував щось подібне? А поки я приведу інсайди, які отримав на практиці.
Тестування
Як на мене, головна перевага підходу з datagatway. Відокремлення джерела отримання об’єкта означає, що цих джерел може існувати нескінченна кількість і вони можуть мати будь-які реалізації. Також це означає, що об’єкт не несе в собі багаж структур, повʼязаних із цим сховищем. Навіть якщо ти майстерно використовуєш dependecy injection — мокати окремий клас легше, ніж цілу ORM-бібліотеку з її інстансем. Приклад юніт-тесту з моком інстансу монгуса я наводити не буду, бо не хочу вчити поганому (їхав any
через any
бачить any
разом з any
). А ось тест через реалізацію з datagatway. Я спеціально скопіпастив складніший тест. Зверни увагу, ми імпортуємо реальні об’єкти, а мокаємо лише «зовнішній» для нашої бізнес-логіки storage. GramyDriver — тут інтерактор, кейс — тест його реакції на dog-delete callback. У тесті ми визначили об’єкти, інтеракції яких ми тестуємо. Перезадали (замокали) шляхи їх отримання. Та після виклику методу processMessage
, що тестуємо, перевірили результат.
import dialogues from "../../index" import { GrammyDriver, GrammyDriverOptions } from "@drivers/telegram/grammy" import { Chat } from "@entities/chat" import { User } from "@entities/user" import { Dog } from "@entities/dog" jest.mock("@drivers/storage")
const mockStorageDriver = StorageDriver as jest.MockedClass<typeof StorageDriver> const mockStorageDriverImpl = mockStorageDriver.prototype
const constructorOptions: GrammyDriverOptions = { storage: mockStorageDriverImpl, routers: dialogues }
describe("dog delete", () => { it("should react on dog-delete-yes callback", async () => { const dog = new Dog({ location: [1, 2], size: "size-s", color: ["біле", "сіре"], furType: "short", ageGroup: "adult" }) const tempChat = new Chat({ chatID: 100, dialogueStep: ["dog", "delete"], stepData: { dog: dog.toDTO() } }) const tempUser = new User({ telegramID: 100, pets: [dog.id] })
mockStorageDriverImpl.getChatBy.mockResolvedValue(tempChat) mockStorageDriverImpl.getUserBy.mockResolvedValue(tempUser)
const grammyDriver = new GrammyDriver(constructorOptions) await grammyDriver.processMessage(createCallbackQuery("dog-delete-yes"))
expect(mockStorageDriverImpl.saveChat).toHaveBeenNthCalledWith(1, expect.objectContaining({ dialogueStep: [], stepData: {} })) expect(mockStorageDriverImpl.dellPet).toHaveBeenNthCalledWith(1, dog.id) }) })
Строга типізація дивиться на цей код ніжним поглядом. Другим бонусом розділення є те, що можна тестувати імплементацію Storage без бізнес-логіки. Юніт-тести стають ну справді юніт.
describe("dellPet", () => { it("should delete pet and updated all users who has it in pets", async () => { const dog = new Dog() const otherID = Dog.makeObjectId() const user = new User({ pets: [dog.id, otherID] })
await mongoDriver.rawDB.collection(mongoDriver.coll.pets).insertMany([dogToMongoMapper(dog)], { ignoreUndefined: true }) await mongoDriver.rawDB.collection(mongoDriver.coll.users).insertMany([userToMongoMapper(user)], { ignoreUndefined: true })
await mongoDriver.dellPet(dog.id)
expect(await mongoDriver.rawDB.collection(mongoDriver.coll.pets).estimatedDocumentCount()).toEqual(0) const userFromDb = await mongoDriver.rawDB.collection(mongoDriver.coll.users).findOne({ _id: user.id }) expect(userFromDb.pets).toEqual([otherID]) }) })
Тут я використовую бібліотеку testcontainers
, яка піднімає реальний інстанс монги, з яким взаємодіє мій класс datagateway — mongoDriver. Ніяких моків, лише жорстка реальність. Еее, а де ORM? Майоре Пейн, я не бачу ORM?!!!! Ну вона є. Чесно-чесно. Просто вона десь усередині реалізації datagateway. Загалом нам все одно, хто і як перекладає дані з властивостей об’єкту Dog у поля документу в монзі. Працює — не чіпай.
High cohesion low coupling
Фраза, яку сивочолі архітектори бачать у вологих снах. Куди вже далі зчеплення (coupling) може бути слабшим за відсутність .save()
у об’єкта? Змінюй бізнес-логіку, як хочеш, вводь властивості, які взагалі не залежать від бази даних. Тестуй бізнес-логіку, коли частини застосунку взагалі ще нема (якщо таке тобі треба. А чи треба?). А що там зі згуртованістю (cohesion)? Datagateway чітко описує всі можливі сценарії використання сутностей, погрупуй їх гарно, й ось тобі — self descriptive code. Хоча згуртованість може перерости всі межі здорового глузду та перетворитись у щось таке:
export interface IStorage { getUserBy(filter: Partial<Pick<IUser, "id" | "telegramID">>): Promise<IUser> saveUser(user: IUser): Promise<void> getChatBy(filter: Partial<Pick<IChat, "id" | "chatID">>): Promise<IChat> saveChat(chat: IChat): Promise<void> getDogBy(filter: {id?:IDog["id"], avatarFileID?:string}): Promise<IDog> saveDog(dog: IDog): Promise<void> dellPet(id: IPet["id"]): Promise<void> getPetsBy(filter: { id?: IPet["id"][], type?: PetTypes, near?: { coordinates: [number, number], maxDistance: number } }): Promise<Array<IDog>> listPetsBy(params: { filter?: { near?: { coordinates: [number, number], maxDistance: number }, status?: PetState, ownedBy?: ObjectId, isValidated?: boolean, }, limit?: number, next?: ObjectId }): Promise<{ list: IDog[], next?: ObjectId }>, listClosetPets (near: { coordinates: [number, number], maxDistance: number }, limit?:number, page?:number): Promise<{list:IDog[], total:number}>, listForValidation(params?: { next?: ObjectId, limit?: number, id?: ObjectId }, withTotal?: boolean): Promise<{ list: { pet: IDog, owners: { telegramID: number }[] }[], total?: number, next?: ObjectId }>, markPetValidated(id: IPet["id"]): Promise<void>, markPetAdoptionValidated(pet: IPet): Promise<void>, countNotValidatedPets (ids: ObjectId[]): Promise<number>, searchDuplicatesDog(dogData: { location: IDog["location"], size: IDog["size"], color: IDog["color"], furType: IDog["furType"], ageGroup: IDog["ageGroup"], }): Promise<IDog[]> getDBStatistics():Promise<{ notValidatedPets:number, totalPets:number, totalPetsWild:number, adoptedPetsToday:number registeredPetsToday:number, totalUsers:number, registeredUsersToday:number, chatsToday:number }> }
Зоопарк методів отримання та збереження даних. Та й опис загальної схеми ORM теж може виглядати жахаючи, або ні. У IStorage-інтерфейсі також можна помітити ще один антипатерн. Методи markPetValidated
, markPetAdoptionValidated
— це можна вважати розмиттям бізнес-логіки за межі інтерактора у сферу бази даних. Cohesion уже не така й high. Але тут це легко помітити, усю кодову базу перечитувати не треба.
Abstraction leak
Це теж трохи задроцька тема. З підходом використання ORM вона зазвичай інжектиться та протікає всюди. Це проявляється в імпортах чи прописуванні інтерфейсів навіть там, де нам взагалі не треба зберігати чи отримувати об’єкт. Але якщо це його невід’ємна частина — нікуди дітися. Datagateway, як дамба, стоїть і блокує собою все, що належить до рівня сховища. Але як і з будь якою додатковою абстракцією, її треба запрограмувати. А це вже не те саме, що просто зробити імпорт.
Flexibility
Як на мене, слабкий аргумент, але він є. Межа абстракції між бізнес-логікою та сховищем дозволяє легше змінювати все, що стосується сховища. Хрестоматійний приклад — використовуйте два різних гейтвея, що підтримують дві різні бази даних, але мають ідентичні інтерфейси. Виходить, що така надскладна операція, як зміна бази даних у проєкті, зводиться до переписування і тестування одної бібліотеки. Але як часто у реальному житті ти міняв базу на ходу? Зробити можна, але робити ніхто не буде.
Сковування сутностями
Datagateway повертає не дані (LOL), він повертає сутності, якими оперує інтерактор. Ніяких тобі частин собачок, чи просто масиву якихось полів. Завжди якусь визначену сутність: Dog, User, Chat. Але ж ми орієнтуємося на бізнес-логіку, тому ці сутності в нас уже створені. А якщо ні, то спочатку доведеться добре подумати над тим, чим ти оперуєш, а вже потім, — як це зберігати. Теоретично такий підхід зробить загальній дизайн застосунку кращим, дозволить виявити проблему на найперших етапах продумування реалізації бізнес-логіки. Або ні, і знову буде якесь нечитабельне месиво, наприклад type PetWithOwners={pet:IDOG, owners:{telegramID:number[]}}
.
А що там з оптимізацією
Це гримучий коктейль з розмиття бізнес-логіки та сковування сутностями. За прямого доступу до ORM ми обмежені лише уважністю код-ревʼю колег. Там, де нам треба, можемо робити супероптимізований SQL-запит, який буде використаний лише раз, тут і зараз. Благо ORM дає нам доступ до найприхованіших методів драйверу бази даних. Якшо треба пʼять джойнів — ну, стадіон так стадіон. В методах datagatewey нам теж ніхто не забороняє зліпити агрегацію на 10 кроків. Але тут уже доведеться подумати про визначення вхідних параметрів методу, узгодити, щоб він зрештою повернув якісь визначенні сутності. Міністерство швидких рішень таке не підпише.
Отже
Остаточної відповіді нема. Хотілося б почитати ще PRO-CON від тебе. Від себе скажу, що якщо доведеться проєктувати застосунок від бізнес-логіки, то як це зробити просто без datagateway — я не знаю.
Не прихований продакт-плейсмент
Якщо хочеш спробувати, як працює написаний за такою схемою застосунок, ось посилання. А якщо ти знаєш тих, кому такий інструмент може знадобитися, — поділись цим посиланням з ними. Зима вже на порозі.
93 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів