Секрети Progressive Web Apps: частина 2

Для тих, хто пропустив першу частину статті: сюди. Ну а для всіх інших, як зазвичай, привіт, Хабрахабр. Ми продовжуємо тему PWA і вивчення базового алгоритму синхронізації (не кидати ж розпочате?). У минулій частині ми закінчили на тому, що наше умовне додаток вміє запитувати статті з сервера, отримувати тільки актуальні матеріали, стежити за змінами і вилученнями статей і грамотно все це обробляти. Працювало це все через обчислення дельти: різниці між тим, що є в додатку, і тим, що зберігається на сервері.

У цій частині ми вивчимо різні конкретні схеми по реалізації описаних вище теорій, обговоримо їх сильні і слабкі сторони. Ну а перед тим як ми почнемо, давайте опишемо вимоги до шуканим алгоритмами.

У нас є два об'єкти з однаковим ID, і нам треба вміти їх розрізняти: сказати, який з них новіше. Тобто визначати, який з об'єктів актуальне. Якщо у нас їх не два, а три, п'ять або десять — по суті нічого не змінюється, треба просто розсортувати їх за ступенем актуальності і мати можливість виділити самий останній, свіжий, актуальний з об'єктів. Власне, нам потрібна система контролю версій.

Збільшуємо лічильник
Перша і найочевидніша ідея — зробити банальний цілочисельний лічильник. «Версія 1», «Версія 2»,… «Версія 100500» — ну ви зрозуміли.

Головний плюс цієї штуки — тут все зрозуміло не тільки комп'ютеру, але і людині. Тобто якщо ви залізете руками в базу даних — вам відразу буде зрозуміло, яка стаття актуальна, а яка — ні. Так в чому ж проблема?

Уявіть, що у нас статтю може редагувати кілька людей. Один — на стороні сервера, інший — на стороні клієнта. Перша редакція статті вийшла з індексом «Версія 1». Далі сервер створює у себе відредагований документ з індексом «Версія 2», а клієнт… а на клієнті теж створюється «Версія 2», тому що за «1» слід «2», а про те, що знаходиться на сервері, клієнт не знав: припустимо, не було доступного підключення в даний момент.

Тепер у нас є два документи з індексом «Версія 2», але ми не знаємо, чи однакові вони чи ні. А для клієнта (і для сервера теж) обидві версії здаються легітимними — лічильник версій-то однаковий, нічого синхронізувати не треба. Значить, наші версії треба описувати більш докладним мовою?

Timestamp's
Щоб розрізняти версії документів з однаковими ID, ми можемо записувати не тільки лічильник, але і час, що стаття була збережена. Чи то 11 жовтня 2016 року, 12:30:57, або просто 111016123057 — нам важливо, щоб число було унікальним і виражало конкретний стан статті.
Ми не будемо вдаватися в подробиці ПРАВИЛЬНОГО опису timestamp'ів, якісь види зручніше обробляти на ПК, які зручніше для користувача. Хочете робити по-своєму — робіть, ми просто рекомендуємо скористатися стандартом ISO 8601.

Що ми маємо в підсумку? У нас є одна і та ж стаття з різними timestamp'ами, за ним легко зрозуміти, яка версія новіше. Так як годинник у нас йдуть тільки вперед, у кожної наступної версії timestamp виявиться точно більше, ніж у попередньої. Чи ні?

Перед тим як ви продовжите читати статтю, подивіться на це чудове збори граблів, на які наступають програмісти, коли думають, що знають, як влаштовано час. У нього ще і друга частина є.

Банальний приклад. Ви познайомилися з чарівною дівчиною в барі, записуєте її номер телефону. Випили ви достатньо, щоб переплутати дві цифри місцями, — і добре, що вирішили перевірити. Ви редагуєте номер, зберігаєте правильну версію, прощаєтеся. У наших контактів є timestamp'и, синхронізація з хмарою пройшла успішно, так що, навіть якщо телефон ви по дорозі додому втратите, телефончик залишиться. Чи ні?

Якщо ви вже відкривали «граблі» з посилання вище, ви зрозумієте, скільки всього могло трапитися з вашою записником при збереженні другого номера. Навіть такі гіганти, як Google, практично з нескінченними ресурсами іноді… стикаються з проблемами, що стосуються часу.

В процесі оновлення записної книжки може трапитися що завгодно. Ви синхронізували першу версію з помилкою, і тут бац! — телефон синхронізувався з сервером і перевів годинник на хвилину назад. Або ви потрапили рівно опівночі, включився режим літнього часу. У iphone'ов досі зустрічаються проблеми з будильниками, встановленими на 1 січня. Загалом, ми не можемо довіряти простим timestamp'ам так, як нам би того хотілося, — у нашому випадку ви втратили телефон дівчини, так як на ранок і пам'ятати не пам'ятали б, що там за опечатка і де… загалом, такого нам не треба. А адже ще й час може бути рассинхронизировано на клієнті і сервері…

Підіб'ємо підсумок наших роздумів: яким би не був конкретний сценарій, проблема може виникнути і в маленькому проекті, і у великій системі. Ви не можете довіряти простим timestamp'ам і гарантувати послідовність редакцій двох об'єктів, навіть якщо обидва timestamp'а були згенеровані на одному пристрої: подібна поведінка може призвести до втрати даних. А це значить, що нам треба удосконалити timestamp'и. Але перед цим треба розглянути ще одне джерело проблем: конфлікти.

Разруливаем конфлікти
Щоб проілюструвати конфлікти, нам треба трохи розширити наш приклад. Уявіть, що у нас не просто клуб читачів цікавих статей, а клуб читачів і письменників. Як Хабрахабр.

Щось у нас вже є і працює: якийсь легасі код є. Сервер вміє опитувати авторів, а їх застосування — віддавати нові та/або змінені статті, а користувачі можуть точно так само запитувати у користувача нові матеріали і економити трафік, так як сервер пришле їм тільки те, що треба. А ще у нас є умовні редактори-модератори та адміністратори, які можуть виправити будь-що в якій завгодно статті.

Тепер ось такий випадок: редактор читав статтю, знайшов помилку виправив її і відправив зміна на сервер. Приблизно в цей же самий час автор дописав апдейт до статті, де сказав, що оновив репозиторій з прикладами коду, подякував уважних читачів за коментарі та зазначені помилки і теж відправив його на сервер.

Що трапиться, якщо обидва ці події сталися з різницею в секунди? Навіть якщо ми 100% знаємо, яка з правок прийшла пізніше, а яка раніше (синхронізували годинник з сервером перед відправкою, перевірили всі мислимі і немислимі проблеми), у нас буде стаття з помилкою, або стаття з помилками в коді.

Власне, це і є конфлікт. В даному випадку — конфлікт версій статті.

Що ж робити?
Не знаю, як ви, а я конфлікти не люблю. І в реальному житті намагаюся їх уникати. Правда, якщо в житті це часто вдається робити, то ось в IT з конфліктами доводиться стикатися постійно. І конфлікти — це дуже, дуже погано. А ще у нас є така штука, як розподілені системи. У цього словосполучення безліч різних трактувань, але в нашому випадку все зводиться ось до чого: у вас є два або більше комп'ютери, з'єднаних певною мережею, і ви намагаєтеся зробити так, щоб якийсь набір даних на всіх комп'ютерах виглядав однаково. А тепер найсмачніше — будь-який елемент мережі може в будь-який момент відмовити. Сценаріїв відмови багато, і передбачити ВСІ — складно. І навіть з базовим сценарієм, де є два пристрої і один сервер, у нас вже є розподілена система, яку треба змусити працювати. Цим і займемося. Почнемо з малого, а грамотно побудоване рішення можна буде і змасштабувати без особливих проблем.

Отже, якщо для редагування документа на одному комп'ютері, як ми вже з'ясували, конфлікти це очень_плохо, то для розподілених обчислень конфлікти — це природний стан речей. Це не означає, що ми можемо їх ігнорувати або робити вигляд, що цього не відбувається, це означає, що ми повинні прийняти конфлікти і зробити їх частиною механіки.

Ми вже обговорили те, як виникають конфлікти (і навіть створили один в прикладі), давайте ж подивимося, як їх вирішувати. Припустимо, в нашому попередньому прикладі помилка була у другому параграфі, а доповнення до матеріалу дописано в кінці статті:

Якщо стаття зберігається у вигляді параграфів, наприклад, то ми б могли просто оновити дві правки в різних параграфах, обробивши ці зміни як дві окремі сутності. У програмістів це називається merge (або злиття, якщо по-російськи). У всяких штук типу git це популярний і дуже зручний механізм. У даному випадку комп'ютер збирає фінальну версію статті без конфліктів, самостійно.

Ускладнимо завдання. Припустимо, автор і редактор одночасно помітили помилку і по-різному її виправили. Автор вирішив підібрати синонім і замінив слово, а редактор — тільки виправив один-два символи в наявному. І обидва практично одночасно відправили зміни на сервер. Тепер комп'ютер не зможе визначити, яка з версій правильна. Що робити?

Як ви вже помітили, ми б'ємо одну глобальну задачу на все більш дрібні й дрібні частини і продовжуємо все глибше і глибше розуміти проблеми синхронізації. Була стаття — тепер параграфи. Параграфи можна розбити на рядки. Рядки — на слова. Рано чи пізно ми досягнемо розумної межі поділу на сутності, і все одно у нас залишаться конфлікти. Більше того, у нас залишаться саме ті конфлікти, які не може вирішити комп'ютер самостійно: попередній приклад тому наочне підтвердження.

Підключити до таких проблем людини нам просто доведеться: ІІ ще не винайдено, так що іншого шляху на даний момент немає. Зрештою, еволюція тільки й робила, що вчила людини виживати і вирішувати конфлікти, так що, думаю, тепер ви не так сильно будете боятися конфліктів в… комп'ютерному сенсі. Ах, так, настав час повернутися до timestamp'ам і поліпшити їх.

Векторні годинник
Як тільки ви почнете шукати рішення для проблем з timestamp'ами, ви натрапите на згадку векторних годин, наприклад, у вигляді тимчасових міток Лэмпорта. Застосовуючи векторні годинник до timestamp'ам ми отримаємо те, що називається логічними годинами. Якщо ми будемо використовувати не тільки timestamp'и, але й логічні годинник з нашими об'єктами, то ми точно зможемо обчислити, яка з правок прийшла раніше, а яка пізніше. Шикарно! Залишилося розібратися ще з дечим.



Пам'ятаєте, коли ми говорили про випадок одночасного виправлення помилки редактором і додавання матеріалу автором? Цей конфлікт ми виправили системою вище: логічними годинами + timestamp'ами. А що якщо їх виправлення відносяться до одного і того ж фрагментом тексту або, що ще цікавіше, результат їх правок — однаковий фрагмент тексту? Тут не допоможуть ніякі хитрощі, конфлікт залишиться. Тобто на перший погляд ніякого конфлікту немає, порівняй результати, викинь зайві і все, делов-то. Але що якщо піти ще далі і не створювати конфлікти замість того, щоб вирішувати їх наслідки?

Зараз ця проблема виглядає дещо надуманою, але, агов… На то це і приклад, щоб потім, коли ви реально зіткнетеся з такою проблемою, так будете працювати не з одним сервером синхронізації, а з цілим серверним кластером, та з тисячами і тисячами клієнтів, у вас не було проблем ні з створенням робочого рішення, ні з масштабуванням його в подальшому.

Дозвольте мені пропустити занудства і перейти до суті, а то наш туторіал і так затягнувся. Загалом, є така штука, як Content Adressable Versions.

Content Adressable Versions
Для того щоб вирішити конфлікти з однаковими правками, ми скористаємося ось яким хаком. Беремо контент правки / об'єкта, проганяємо через хеш-функцію (через той же MD5 або SHA-1) і використовуємо хеш в якості версії. Так як ідентичні вихідні дані дають ідентичні хеші, то правки, результат яких однаковий, не створюють конфлікту.

Правда, це не вирішує проблему з черговістю правок, але це ми зараз виправимо. Для початку, замість того, щоб перезаписувати документ з кожним оновленням, ми будемо зберігати впорядкований список версій разом з документом. Коли ми оновлюємо частина документа, Content Adressable Version записується разом з правками у верхівку листа. Тепер ми точно знаємо, коли і яка версія правок надійшла. Загалом штука корисна, але варто заздалегідь визначитися, скільки правок і варіантів документу ми будемо зберігати або передавати на сервер. Ви ж пам'ятаєте, заради чого все це затівалося? Для оптимізації роботи програми, зменшення обсягів трафіку, займаної пам'яті і так далі. Так що в реальному розробці задумайтеся, скільки правок вам треба «тягнути з собою»: у загальних випадку число від 10 до 1000 підійде, а підібрати правильний параметр вам допоможе тільки особистий досвід і знання завдань, ЦА і того, як ваш додаток використовується.

У підсумку ми витрачаємо трохи більше місця на зберігання історії правок, але маємо гнучку систему, яка уникає конфліктів частини.

Вам може здатися, що простіше використовувати ті ж Векторні Годинник і вирішувати конфлікти по мірі їх надходження, ніж городити таку складну штуку і витрачати місце на зберігання непотрібних даних, але, повірте, все зміниться, як тільки замість одного сервера у вас буде кластер незалежних серверів, обробляють запити, і необхідність надати консистентні дані для всіх користувачів.

Зберігаємо конфлікти ефективним чином
Нарешті, нам залишилося вирішити останню задачу. Коли один користувач послідовно вносив правки в один і той же матеріал, історія версій виглядає приблизно так (для простоти замість хешей ми використовуємо прості числа):


У нас є п'ять версій. Тепер штучно створимо конфлікт: у списку версій після 4 у нас буде йти конфлікт 5-ї і 6-ї версії:


Ми знаємо, що ці версії конфліктують. А що якщо замість вирішення цього конфлікту ми створили ще один? Просто запишемо в лист зверху ще кілька значень:

Тепер у нас є конфлікт версій «6» і «7», і сам цей конфлікт конфліктує з версією «5». Хтось упрлс? Немає. Насправді наша історія редагувань перетворюється в дерево версій. У нього є кілька плюсів:

  1. Воно зберігає конфлікти ефективним чином;
  2. Ми можемо вирішувати конфлікти рекурсивно: неважливо, скільки у нас конфліктуючих правок і гілок, ми завжди можемо повернутися в стан, в якому не було жодних конфліктів.
Збираємо все воєдино
Отже, підіб'ємо підсумки. Ми разом розібрали і прояснили багато (іноді здаються очевидними) аспекти синхронізації. Тепер подивимося, що у нас вийшло, якщо взяти всі напрацювання і з'єднати їх. Нижче приведені ключові моменти описаного вище алгоритму, без пояснень з приводу того, навіщо це все робиться, так як ми вже розбирали ці питання:

1. Є два пристрої — А і Б; необхідно синхронізувати інформацію на них (А, Б).
2. Зчитуємо чекпойнт синхронізації, якщо він існує.
3. Починаємо зчитувати оновлення інформації з пристрою А; зчитуємо з чекпойнта, якщо він існує, або з самого початку, якщо його не було.

  • Розглядаємо кожну пару виду Id/версія;
  • Якщо це видалення, запам'ятовуємо видалення локально на вашому пристрої Б;
  • Якщо такої пари не було Б, зчитуємо її з А і запам'ятовуємо локально на Б;
  • Зберігаємо чекпойнт оновлення на А і Б.
4. Перевіряємо за списком версій, є поточна версія прямим продовженням останньої версії Б.
  • Якщо так, зберігаємо її на пристрої Б;
  • Якщо ні, створюємо конфлікт.
На
Спасибі, що дійшли до кінця. Сподіваюся, ви зрозуміли, наскільки складно грамотно і з першого разу розробити дієву та ефективну синхронізацію, яка буде надійною і не перетвориться для вас в одну велику біль при масштабуванні програми. Ну і ми обговорювали синхронізацію не заради самої синхронізації, а в контексті застосування її в PWA, так що використовуйте отримані знання на практиці.

До речі, спочатку я вам не сказав, але ми «розбирали» на наочному рівні CouchDB Replication Protocol, в якому все це враховано: він дозволяє створити прозору синхронізацію на базі p2p з будь-якою кількістю користувачів і враховує всі сценарії, описані вище. У свою чергу, цей протокол є частиною бази даних CouchDB, так що і з серверами він працює прекрасно. У браузері ж і на локальних машинах можна застосовувати PouchDB: аналогічний продукт, що реалізує той же протокол, але на JavaScript і node.js. Таким чином ви можете використати зв'язку CouchDB+PouchDB і покрити всі необхідні юзкейсы. Само собою, на мобільниках ці технології теж представлені у вигляді Couchbase Mobile і Cloudant Sync для iOS і Android відповідно. Так що, як я і говорив на самому початку першого поста:


Користуйтеся готовим продуктом. Але не забувайте розбиратися в технології досконально, щоб рано чи пізно створити свій. На цьому все.

PS. А для тих, хто вирішив освоїти Progressive Web Apps пропонуємо подивитися опубліковані на YouTube запис доповідей з нещодавно пройшла онлайн-конференції PWA Day.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.