Нетворкінг у Flutter додатках — про просте і складне на прикладі Tide. Частина 6: REST API запити з retrofit. Про складне

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт! Я Анна — експертка з мобільної розробки, GDE з Dart та Flutter, досвідчена розробниця мобільних додатків на Flutter.

Більшість додатків, чи то мобільні, чи то веб, чи десктоп, залежать від того чи іншого бекенда. Отже, імплементація комунікації з API є невід’ємною частиною реалізації додатку. У цій серії з шести частин представлені інструменти та підходи, які полегшують розробку комунікації з API у Flutter додатках, які ми використовуємо в Tide.

Обіцяю, буде корисно і цікаво розробникам будь-якого рівня!

Read in English

Якщо загубилися, почніть читати з початку.

Частини 5 і 6 цієї серії присвячені ефективній реалізації REST API запитів.

Ця частина має на меті показати, як ми в команді налаштовуємо хедери для окремих / групи / більшості / всіх запитів до API за допомогою згенерованого коду від retrofit та dio перехоплювачів.

В цій частині про складне:

  1. хедери для окремих запитів
  2. хедери для більшості / всіх запитів
  3. хедери для групи запитів
  4. дії до / після запитів

Про просте читайте в Частині 5 цієї серії:

  1. простий API запит
  2. атрибути API запитів
  3. generic API відповіді

На момент випуску цієї серії актуальна версія Flutter 3.0.

Приклади будуються на основі коду, створеного в Частині 5, який знаходиться під тегом part-5 у Flutter Advanced Networking GitHub репозиторії.

1. Хедери для окремих запитів

Якщо разом із окремим API запитом необхідно надсилати заздалегідь відомі статичні хереди, його можна анотувати атрибутом @Headers, як у рядку 10:

Згенерований файл .g.dart оголошує мапу _headers з тими ж значеннями в рядку 11 і додає їх до хедерів запиту в рядку 18:

Розповім про хитрість, що робити, якщо є потреба вказувати ці заголовки знову і знову. Dart дозволяє використовувати будь-яке const значення як анотацію. Отже, реалізація вище повністю ідентична наведеній нижче. Тут результат виклику конструктора Headers зберігається в const об’єкті popularHeaders у рядку 5, який потім використовується як анотація в рядку 14:

Ця реалізація генерує той самий код, що й вище.

Атрибут @Header дозволяє задати один динамічний хедер окремого запиту. Для прикладу в рядку 10:

згенерований файл .g.dart використовує те саме значення хедеру в рядку 11 і додає його до хедерів запиту в рядку 15:

Той же трюк зі створенням анотації можна застосувати до атрибута @Header. Тут Header('header-name') зберігається в const полі popularHeader, а замість нього в рядку 12 використовується анотація @popularHeader:

Згенерований код такий самий. Тепер @popularHeader можна використовувати в інших запитах без повторення імені хедеру.

Щоб надсилати кілька динамічних хедерів з окремим запитом, можна їх надавати один за одним, як було щойно показано, або знову ж через атрибут @Headers, але як у рядку 10:

Згенерований файл .g.dart використовує параметри header1Value і header2Value як хедери у рядках 12 і 13:

Атрибут @Headers з динамічними хедерами також можна зберегти до const анотації:

Однак, щоб цей підхід працював, назви параметрів методу мають відповідати значенням у @popularDynamicHeaders, а про цю вимогу легко забути, якщо оголошення анотації розташовано в іншому файлі.

2. Хедери для більшості / всіх запитів

Якщо до всіх API запитів потрібно додавати одну й ту саму групу хедерів, це можна ефективно зробити за допомогою dio перехоплювачів. Пам’ятаєте MarvelApiAuthInterceptor з попередньої частини, який додає необхідні параметри для здійснення авторизованих запитів до Marvel Comic API? Ідея така ж сама: створити dio перехоплювач, який додаватиме необхідні статичні хедери або хедери, значення яких можна отримати незалежно від запиту, до всіх запитів. Динамічні хедери не можуть бути додані через dio перехоплювачі, оскільки їх значення відоме лише на момент виклику API.

Цей ExampleInterceptor додає один статичний хедер у рядку 16, один змінний хедер, який відрізняється для кожного запиту в рядку 19, і один хедер, отриманий із зовнішнього джерела в рядку 21:

Коли доданий до екземпляра dio, він надсилає цю групу хедерів з кожним запитом. Якщо є лише кілька запитів, які не повинні містити один із хедерів, наприклад, запити /login і /forgot-password не потребують access-key хедера, ExampleInterceptor все ще можна використовувати з незначними змінами, як у рядку 10 і 16:

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

3. Хедери для групи запитів

Ми розробляємо досить великий мобільний додаток із сотнями API викликів. Серед інших хедерів, які додаються до кожного запиту, є п’ять особливих хередів, які надсилаються з деякими або більшістю запитів до API. Вони представляють інформацію, глобально доступну в додатку, як-от ключі доступу, деякі ідентифікатори, інформацію про пристрій тощо. Для простоти назвемо їх "header1", "header2", ... "header5".

Усі запити до нашого API вимагають від двох до чотирьох із цих хередів. Так, наприклад, requestA вимагає "header1" і "header2" , requestB — "header2", "header3" і "header5", requestC — "header2", "header3", "header4" і "header5" і requestD вимагає лише "header2" і "header4". Тут requestA … requestD скоріше означають типи запитів, ніж якісь конкретні запити.

Тільки уявіть, як може виглядати надання цих хередів до окремих запитів з атрибутом @Header:

Щоб використовувати HeadersExampleApi, розробникам доведеться знову і знову отримувати ту саму інформацію із загальнодоступних джерел:

Пам’ятаючи, що в нашому проекті сотні таких запитів requestA, requestB та інших, важко уявити, до якого обсягу дублювання коду призведе цей підхід. Чи не було б круто підключити dio перехоплювачі, які додавали б хереди лише до необхідних запитів? Але як повідомити перехоплювача про те, які запити вимагають які хереди? В ідеалі перехоплювач повинен виглядати приблизно так:

Насправді, ми створили окремі перехоплювачі, призначені для кожного хедера, але для простоти прикладу всі вони можуть бути оброблені в одному перехоплювачі. Отже, яка ця <some condition> має бути? Враховуючи масштаб нашого проекту, підхід зі списком винятків не є прийнятним рішенням. Нам був потрібен простий спосіб позначити кожен запит набором необхідних йому хередів, який був би зрозумілим для dio перехоплювача. Перш ніж розповісти про наше рішення, познайомлю час з ще одним атрибутом — @Extra.

Атрибут @Extra призначений для зберігання кастомних полів. Він дозволяє разом з запитом надати додаткову Map<String, Object>, яку пізніше можна прочитати в dio перехоплювачах. Для атрибута @Extra в рядку 9:

згенерований файл .g.dart містить таке ж значення в рядку 10:

Тепер його можна прочитати в dio перехоплювачі, як у рядку 8:

Ми також очищаємо extra мапу в рядку 16, щоб жоден інший перехоплювач не обробляв ті самі дані.

Дотримуючись цього підходу, ми розділили всі запити до API на групи на основі хедерів, які їм потрібні, і створили спеціальні анотації для кожної групи:

Коли така анотація застосовується до запиту, як у рядках 9 і 13:

згенерований файл .g.dart містить скопійовані значення, як у рядках 10 і 20:

Тепер їх можна прочитати в dio перехоплювачі, щоб вирішити, чи додавати хедери:

А користувачі HeadersExampleApi вільні від надання нерелевантної (з їх точки зору) інформації і можуть зосередитися на передачі лише тих даних, які мають значення:

Нагадаю, що немає відповідності один до одного між анотацією з типом запиту та самим запитом. Анотації requestAType, … requestDType можна застосувати до будь-якого retrofit запиту, і це призведе до додавання необхідних хередів, якщо AppendHeadersInterceptor приєднано до екземпляра dio. Наприклад, тут запит .getComics() з MarvelComicsApi анотується атрибутом @requestAType у рядку 9:

В результаті разом з запитом буде відправлено "header1" і "header2".

4. Дії до / після запитів

Оскільки наша програма дозволяє керувати банківськими рахунками користувачів, деякі операції є особливо чутливими до безпеки, й вимагають від користувачів вводити свій PIN-код або використовувати біометричні дані. Це надає нам можливість отримати доступ до зашифрованого сховища та зчитати деякі дані, щоб передати їх на сервер. Для простоти скажімо, що ми наївно запитуємо користувачів, чи погоджуються вони поділитися секретними даними за допомогою простого «Так / Ні» діалога:

Очевидна реалізація полягає в тому, щоб викликати .getSecretData() перед викликом кожного запиту API, чутливого до безпеки:

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

Ви можете подумати: «Зачекай, я знаю рішення: використай анотацію @Extra та додай інший маркер до необхідних запитів!». Ми також так думали, але виявляється, що поверх retrofit запиту можна додати лише один атрибут типу @Extra. Якщо їх кілька, як у рядках 10 і 11:

у створеному файлі .g.dart буде використаний лише перший:

Тож нам довелося використати інший атрибут, який міг би анотувати retrofit запит та впливати на згенерований код — атрибут @Headers. Перевага використання атрибута @Extra полягала в тому, що навіть якщо до екземпляра dio не приєднано жодного перехоплювача, і ніхто не очистить мапу _extra, мати в ній якісь дані не шкідливо, оскільки насправді вони не використовуються, коли запит надсилається до бекенду. З @Headers, якщо жоден перехоплювач не прослуховує dio і не замінює маркуючі хедери чимось біль значущим, вони будуть відправлені «як є» і можуть викликати непорозуміння з бекендом.

Отже, ми створили нову анотацію:

І dio перехоплювач, який буде чекати та обробляти такий хедер:

У рядку 14 він визначає, чи мапа з хедерами містить хедер зі спеціальним значенням secureActionHeader. Якщо так, перехоплювач запитує секретні дані в рядку 16. Якщо запит був успішним, дані додаються як значення хедера security-header, і запит продовжує виконуватись в звичайному режимі. В іншому випадку запит до API відхиляється в рядку 20. Також ми очищаємо нерелевантне значення хедера в рядку 15.

Тепер @secureAction можна використовувати над retrofit запитом:

А користувачі API можуть забути про деталі його реалізації:

Анотацію @secureAction можна використовувати над будь-яким retrofit запитом, тому тут вона анотує метод .getComics():

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

Той самий підхід може бути застосований для дій, що треба виконувати після запитів. Все залежить від того, чи код в перехоплювачі виконується до виклику super.onRequest(options, handler) чи після.

Результат

Ми впровадили різні способи налаштування викликів до API на рівні retrofit для окремих запитів, а також глобально в dio перехоплювачах.

Остаточна версія коду, розробленого в цій частині, знаходиться під тегом part-6 у Flutter Advanced Networking GitHub репозиторії.

Заключні слова

Як і обіцяла, ми розробили програму, яка відображає список Marvel коміксів, отриманих з Marvel Comic API. Метод .getComics() з MarvelComicsApi використовується для отримання списку коміксів, а результат відображається в дуже простому інтерфейсі:

Остаточну версію коду з повністю реалізованим Flutter додатком можна зайти у Flutter Advanced Networking GitHub репозиторії.

Інструменти та підходи, описані в цій серії, максимально наближені до тих, що ми створили в Tide під час розробки наших мобільних додатків на Flutter. Якщо ви так само як і ми в захваті від цієї технології, відвідайте нашу сторінку з вакансіями, ми наймаємо мобільних Flutter розробників у різних країнах, в тому числі й віддаленно в Україні. Ми з нетерпінням чекаємо зустрічі з професіоналами-однодумцями!

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Ті же яйця в профіль — С# Attributes, js Decorators
В чому фішка?

Фішка в тому, що мало хто знає, що:
1. Анотацією у Dart може бути будь-яка константа.
2. Існує анотація @extra, що дозволяє доставити дані зі згенерованої пакетом retrofit імплементації до dio інтерсептору.
3. Можна поєднати 1 і 2, і створити власні анотації на кшталт @requestAType на базі цієї @extra, щоб постійно не повторювати @Extra(<String, Object>{appendHeader1: true,appendHeader2: true, ...});.
Йдеться не тільки про існування такого механізму як анотації, а як саме їх можна застосовувати у поєднанні з retrofit пакетом й хакнути принципи його роботи на свою користь. Такого не знайдете в документації.
Мета серії — розповісти в деталях про підхід, який ми використовуємо у величезному проекті для ефективної реалізації комунікації з бекендом, незважаючи на те, чи ті ж механізми існують в інших технологіях.

Дякую за серію статей! Зайшло як серіал)
Буду чекати продовження про тести

Дякую за відгук. Приємно чути, що зайшло 🙂
Продовження про тести може й буде, але точно не скоро. То ж поки що про основи нашого підходу до тестування можна подивитись у цьому відео, де автор пакету bdd_widget_test — Олександр Леущенко — розповідає про варіанти його використання. Ми майже всі їх застосовуємо у Tide.

Як на мене це мабуть один з тих прикладів коду, який використовується одиницями в світі. Тому що написати таке — просто мозок поламати можна. Тут навіть прочитати оце все нереально. :)

Як в тому приколі: це дуже цікаво, але не зрозуміло.

P.S. Читаю цей код і розумію, що я весь Dart вивчити нереально... :)

Дякую за відгук, Ярославе.
Намагалася якомога простіше описати підходи, що ми розробили в компанії.
Сподіваюся, що в серії загалом кожен Flutter розробник знайде для себе щось нове і корисне незалежно від рівня досвіду.

Підписатись на коментарі