Грамотна клієнт-серверна архітектура: як правильно проектувати і розробляти web API


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

Розповідає Володимир, веб-розробник Noveo


Більшості розробників сайтів, веб-сервісів і мобільних додатків рано чи пізно доводиться мати справу з клієнт-серверною архітектурою, а саме: розробляти web API або інтегруватися з ним. Щоб не винаходити  щоразу щось нове, важливо виробити відносно універсальний підхід до проектування web API, ґрунтуючись на досвіді розробки подібних систем. Пропонуємо Вашій увазі об’єднаний цикл статей, присвячених цьому питанню.

Наближення перше: дійові особи

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

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

Клієнт і сервер

Сервером в даному випадку ми вважаємо абстрактну машину в мережі, здатну отримати HTTP-запит, обробити його і повернути коректну відповідь. У контексті цієї статті абсолютно не важливі її фізична суть і внутрішня архітектура, будь то студентський ноутбук або величезний кластер з промислових серверів, розкиданих по всьому світу. Нам так само абсолютно неважливо, що у нього під капотом, хто зустрічає запит у дверей, Apache або Nginx, який невідомий звір, PHP, Python або Ruby виконує його обробку і формує відповідь, яке сховище даних використовується: Postgresql, MySQL або MongoDB. Головне, щоб сервер відповідав правилу – почути, зрозуміти і відповісти.

Клієнтом також може бути все, що завгодно, що здатне сформувати і відправити HTТР-запит. До певного моменту в цій статті нам також не особливо будуть цікаві цілі, які ставить перед собою клієнт, відправляючи цей запит, як і те, що він робитиме з відповіддю. Клієнтом може бути JavaScript-сценарій, який працює у браузері, мобільний додаток, злий (чи не дуже) демон, запущений на сервері, або холодильник (вже є і такі), що занадто порозумнішав.

Здебільшого ми говоритимемо про спосіб спілкування між названими вище двома, такий спосіб, щоб вони один одного розуміли, і ні в кого не залишилося запитань.

Філософія REST

REST (Representational state transfer) спочатку був задуманий як простий і однозначний інтерфейс для управління даними, що припускав всього декілька базових операцій з безпосереднім мережевим сховищем (сервером): витягання даних (GET), збереження (POST), зміна (PUT/PATCH) і видалення (DELETE). Зрозуміло, цей перелік завжди супроводжувався такими опціями, як обробка помилок у запиті (чи коректно складений запит), розмежування доступу до даних (раптом  Вам знати  цього не треба) і валідація вхідних даних (раптом Ви написали нісенітницю), всіма можливими перевірками, які сервер виконує перед тим, як виконати бажання клієнта.

Крім цього, REST має низку архітектурних принципів, перелік яких можна знайти у будь-якій іншій статті про REST. Побіжно переглянемо їх:

Незалежність сервера від клієнта – сервери і клієнти можуть бути миттєво замінені іншими незалежно один від одного, оскільки інтерфейс між ними не міняється. Сервер не зберігає станів клієнта.
Унікальність адрес ресурсів – кожна одиниця даних (будь-якого ступеня вкладеності) має власний унікальний URL, який, по суті, цілком є однозначним ідентифікатором ресурсу.

Приклад: GET /api/v1/users/25/name

Незалежність формату зберігання даних від формату їх передачі – сервер може підтримувати декілька різних форматів для передачі тих самих даних (JSON, XML і так далі), але зберігає дані у своєму внутрішньому форматі, незалежно від підтримуваних.

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

Чого нам бракує?

Класичний REST передбачає роботу клієнта із сервером як з плоским сховищем даних, при цьому нічого не говориться про зв’язаність і взаємозалежність даних між собою. Все це за умовчанням покладається на клієнтський додаток. Проте сучасні предметні області, для яких розробляються системи управління даними, наприклад, соціальні сервіси або системи інтернет-маркетингу, передбачають складний взаємозв’язок між сутностями, що зберігаються у базі даних. Підтримка цих зв’язків, тобто цілісності даних, знаходиться в зоні відповідальності серверної сторони, в той час, як клієнт є тільки інтерфейсом для доступу до цих даних. Так чого ж нам бракує в REST?

Виклики функцій

Щоб не міняти дані й зв’язки між ними вручну, ми просто викликаємо у ресурсу функцію і “згодовуємо” їй як аргумент необхідні дані. Ця операція не підходить під стандарти REST, для неї немає особливого дієслова, що примушує нас, розробників, демонструвати хто на що здатний.

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

Ще варіант – створення і розрив зв’язків між даними. Наприклад, додавання користувача в групу. Викликаємо у сутності група функцію addUser, як параметр передаємо об’єкт користувач, отримуємо результат.

А ще бувають операції, які взагалі не пов’язані безпосередньо зі збереженням даних як таких, наприклад, розсилка повідомлень, підтвердження або відхилення певних операцій (завершення звітного періоду etc).

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

Множинні операції

Часто буває так, і розробники клієнтів зрозуміють, про що я, що клієнтському пристрою зручніше створювати/змінювати/видаляти/ відразу декілька однорідних об’єктів одним запитом, і за кожним об’єктом можливий свій вердикт серверної сторони. Тут є, як мінімум, декілька варіантів: або всі зміни виконані, або вони виконані частково (для частини об’єктів), або сталася помилка. Ну і стратегій теж декілька: застосовувати зміни тільки у разі успіху для всіх, або застосовувати частково, або “відкочуватися” у разі будь-якої помилки, а це вже подібне до повноцінного механізму транзакцій.

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

Статистичні запити, агрегатори, форматування даних

Часто буває так, що на основі даних, які зберігаються на сервері, нам треба отримати статистичну вибірку або дані, відформатовані по-особливому, наприклад, для побудови графіка на стороні клієнта. По суті, це дані, генеровані на вимогу, на льоту, і доступні тільки для читання, так що має сенс винести їх в окрему категорію. Однією з відмітних особливостей статистичних даних, на мій погляд, є те, що вони не мають унікального ID.

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

Різновиди даних

Об’єкти

Колекції об’єктів

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

Скалярні значення

У чистому вигляді скалярні значення як окрема сутність зустрічалися украй рідко. Зазвичай вони фігурували як властивості об’єктів або колекцій, і в цій якості вони можуть бути доступними як для читання, так і для запису. Наприклад, ім’я користувача може бути отримане і змінене в індивідуальному порядку GET /users/1/name. На практиці ця можливість рідко потрібна, але за потреби хотілося б, щоб вона була під рукою. Особливо це стосується властивостей колекції, наприклад, числа записів (із фільтрацією або без неї): GET /news/count.

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

Наближення друге: правильний шлях

У цьому наближенні я хотів би окремо поговорити про підходи до побудови унікальних шляхів до ресурсів і методів Вашого web API і про ті архітектурні особливості додатка, які впливають на зовнішній вигляд цього шляху та його компоненти.

Про що варто подумати, стоячи на березі?

Версійність

Рано чи пізно будь-яка чинна система починає еволюціонувати: розвиватися, ускладнюватися, масштабуватися, вдосконалюватись. Для розробників REST API це відгукнеться, в першу чергу тим, що необхідно запускати нові версії API за працюючих старих. Тут я говорю не про архітектурні зміни під капотом Вашої системи, а про те, що змінюється сам формат даних і набір операцій з ними. У будь-якому випадку версійність треба передбачити як у первинній організації вихідного коду, так і в принципі побудови URL. Що стосується URL, тут є два найбільш популярних способи вказівки версії API, якій адресований запит. Префіксація шляху example-api.com/v1/ і розведення версій на рівні субдомена v1.example - api.com . Використати можна будь-якій з них, залежно від потреби і необхідності.

Автономність компонентів

Формат обміну даними

Найзручнішим і функціональним, на мій погляд, форматом обміну даними є JSON, але ніхто не забороняє використати XML, YAML або будь-який інший формат, що дозволяє зберігати серіалізовані об’єкти без втрати типу даних. За бажання можна зробити в API підтримку декількох форматів введення/виведення. Достатньо задіяти HTTP заголовок запиту для вказівки бажаного формату відповіді Accept і Content - Type для вказівки формату переданих у запиті даних. Іншим популярним способом є додавання розширення до URL ресурсу, наприклад, GET /users.xml, але такий спосіб здається менш гнучким і красивим, хочаб тому, що обважнює URL і правильний швидше для GET- запитів, ніж для всіх можливих операцій.

Локалізація і багатомовність

Внутрішня маршрутизація

Отже, ми дісталися до кореневого вузла нашого API (чи одного з його компонентів). Усі подальші маршрути проходитимуть вже безпосередньо всередині Вашого серверного додатка, відповідно до підтримуваного ним набору ресурсів.

Шляхи до колекцій

Для вказівки шляху до колекції ми просто використовуємо назву відповідної сутності, наприклад, якщо це список користувачів, то шлях буде таким /users . До колекції застосовують два методи: GET (отримання лімітованого списку сутностей) і POST (створення нового елемента). У запитах на отримання списків ми можемо використати багато додаткових GET-параметрів, вживаних для посторінкового виведення, сортування, фільтрації, пошуку etc, але вони мають бути опціональними, тобто ці параметри не повинні передаватися як частина шляху!

Елементи колекції

Для звернення до конкретного елемента колекції ми використовуємо в маршруті його унікальний ідентифікатор /users/25 . Це і є унікальний шлях до нього. Для роботи з об’єктом застосовують методи GET (отримання об’єкта), PUT/PATCH (зміна) і DELETE (видалення).

Унікальні об’єкти

У багатьох сервісів є унікальні для поточного користувача об’єкти, наприклад профіль поточного користувача /profile, чи персональні налаштування /settings . Зрозуміло, з одного боку, це елементи однієї з колекцій, але вони є відправною точкою у використанні нашого Web API клієнтським додатком, і до того ж дозволяють набагато ширший спектр операцій над даними. При цьому колекція, що зберігає користувацькі налаштування, може бути взагалі недоступна з міркувань безпеки і конфіденційності даних.

Властивості об’єктів і колекцій

Для того щоб дістатися до будь-якої з властивостей об’єкта безпосередньо, достатньо додати у шлях до об’єкта ім’я властивості, наприклад отримати ім’я користувача /users/25/name . До властивості застосовують методи GET (набуття значення) і PUT/PATCH (зміна значення). Метод DELETE не застосовують, оскільки властивість є структурною частиною об’єкта як формалізованої одиниці даних.

У попередній частині ми говорили про те, що у колекцій, як і в об’єктів, можуть бути властивості. Мені згодилася тільки властивість count, але Ваш додаток може бути складнішим і специфічним. Шляхи до властивостей колекцій будуються за тим же принципом, що і до властивостей їх елементів: /users/count . Для властивостей колекцій застосовуємо метод GET (набуття властивості), оскільки колекція – це інтерфейс для доступу до списку.

Колекції пов’язаних об’єктів

Одним з різновидів властивостей об’єктів можуть бути пов’язані об’єкти або колекції пов’язаних об’єктів. Такі сутності, як правило, не є властивістю об’єкта, а лише відсиланнями до його зв’язків з іншими сутностями. Наприклад, перелік ролей, які були присвоєні користувачеві /users/25/roles . З приводу роботи з вкладеними об’єктами і колекціями ми детально поговоримо в одній з наступних частин, а на цьому етапі нам достатньо того, що ми маємо можливість звертатися до них безпосередньо, як до будь-якої іншої властивості об’єкта.

Функції об’єктів і колекцій

Для побудови шляху до інтерфейсу виклику функції у колекції або об’єкта ми використовуємо той самий підхід, що і для звернення до властивості. Наприклад, для об’єкта /users/25/sendPasswordReminder або колекції /users/disableUnconfirmed. Для викликів функцій ми у будь-якому випадку використовуємо метод POST. Чому? Нагадаю, що в класичному REST немає спеціального дієслова для виклику функцій, а тому нам доведеться використати одне з наявних. На мій погляд, для цього найбільше підходить метод POST оскільки він дозволяє передавати на сервер необхідні аргументи, не є ідемпотентним (що повертає той самий результат у випадку багаторазового звернення) і найбільш абстрактний за семантикою.

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

Наближення третє: запити і відповіді

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

Універсальна відповідь

Ми вже розповідали, що конкретний формат спілкування сервера з клієнтом може бути будь-яким на розсуд розробника. Для мене найбільш зручним і наочним є формат JSON, хоча в реальному додатку може бути реалізована підтримка декількох форматів. Зараз же зосередимося на структурі та необхідних атрибутах об’єкта відповіді. Так, усі дані, повернені сервером, ми обертатимемо в спеціальний контейнер – універсальний об’єкт відповіді, який міститиме всю необхідну сервісну інформацію для його подальшої обробки. Отже, що це за інформація?

Success – маркер успішності виконання запиту

Для того щоб при отриманні відповіді від сервера відразу зрозуміти, чи увінчався запит успіхом, і передати його відповідному обробникові, достатньо використати маркер успішності “success”. Найпростіша відповідь сервера, що не містить жодних даних, набуде такого вигляду:

POST /api/v1/articles/22/publish{ " success": true}

Error – відомості про помилку

Якщо виконання запиту завершилося невдачею – про причини і різновиди негативних відповідей сервера поговоримо трохи пізніше, – до відповіді додається атрибут “error”, що містить HTTP- код статусу і текст повідомлення про помилку. Прошу не плутати з повідомленнями про помилки валідації даних для конкретних полів. Найправильніше, на мій погляд, повертати код статусу і в заголовку відповіді, але я зустрічав також інший підхід – у заголовку завжди повертати статус 200 (успіх), а деталі й можливі дані про помилки передавати в тілі відповіді.

GET /api/v1/user{ " success": false, " error": { "code": 401, " message": "Authorization failed" }}

Data – дані, повертані сервером

Більшість відповідей сервера покликані повертати дані. Залежно від типу запиту і його успіху очікуваний набір даних буде різним, проте атрибут “data” буде в переважній більшості відповідей.

Приклад повернених даних у разі успіху. В даному випадку відповідь містить запитуваний об’єкт user.

GET /api/v1/user{ " success": true, " data": { "id": 125, " email": "john.smith@somedomain.com", " name": " John", " surname": " Smith" }}

Приклад повернених даних у разі помилки. В даному випадку містить імена полів і повідомлення про помилки валідації.

PUT /api/v1/user{ " success": false, " error": { "code": 422, " message": "Validation failed" } "data": {
"email": "Email could not be blank"., }}

Pagination – відомості, необхідні для організації посторінкової навігації

Крім власне даних, у відповідях, що повертають набір елементів колекції, обов’язково має бути інформація про посторінкову навігацію (пагінації) за результатами запиту.

Мінімальний набір значень для пагінації складається із:

  • загального числа записів;
  • числа сторінок;
  • номера поточної сторінки;
  • числа записів на сторінці;
  • максимального числа записів на сторінці, підтримуваного серверною стороною.

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

GET /api/v1/articlesResponse:{ "success": true, " data": [ {
"id": 1, " title": "Interesting thing", }, { "id": 2, " title": "Boring text", } ], "pagination": { "totalRecords": 2, " totalPages": 1, " currentPage": 1, " perPage": 20, " maxPerPage": 100 }}

Робота над помилками

Як вже згадувалося вище, не всі запити до web API завершуються успіхом, але це також частина гри. Система інформування про помилки є потужним інструментом, що полегшує роботу клієнта і спрямовує клієнтський додаток правильним шляхом. Слово “помилка” в цьому контексті є не зовсім доречним. Тут більше підійде слово виняток, оскільки насправді запит успішно отриманий, проаналізований, і на нього повертається адекватна відповідь, що пояснює, чому запит не може бути виконаний.

Які ж потенційні причини отримуваних винятків?

500 Internal server error – все зламалося, але ми швидко полагодимо

Це якраз той випадок, коли проблема сталася на стороні самого сервера, і клієнтському додатку залишається тільки повідомити користувача про те, що сервер “утомився і приліг відпочити”. Наприклад, загублено з’єднання з базою даних або в коді завівся баг.

400 Bad request – а тепер у Вас все зламалося

Відповідь прямо протилежна попередній. Повертається в тих випадках, коли клієнтський додаток відправляє запит, який в принципі не може бути коректно оброблений, не містить обов’язкових параметрів або має синтаксичні помилки. Зазвичай це лікується повторним прочитанням документації до web API.

401 Unauthorized – незнайомець, назви себе

Для доступу до цього ресурсу потрібна авторизація. Зрозуміло, наявність авторизації не гарантує того, що ресурс стане доступним, але не авторизувавшись, Ви точно цього не дізнаєтеся. Виникає, наприклад, при спробі звернутися до закритої частини API або при закінченні терміну дії поточного токена.

403 Forbidden – Вам сюди не можна

Запитуваний ресурс є, але у користувача недостатньо прав на його перегляд або модифікацію.

404 Not found – за цією адресою ніхто не проживає

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

405 Method not allowed – не можна такого робити

Цей різновид винятку безпосередньо пов’язаний з використаним у запиті дієсловом (GET, PUT, POST, DELETE), яке, у свою чергу, свідчить про дію, яку ми намагаємося вчинити з ресурсом. Якщо запитуваний ресурс не підтримує вказану дію, то сервер говорить про це прямо.

422 Unprocessable entity – виправіть і надішліть знову

Один з найкорисніших винятків. Повертається щоразу, коли в даних запиту є логічні помилки. Дані запиту ми розуміємо як набір параметрів і значень, що відповідають їм, переданих методом GET, або поля об’єкта, що передається в тілі запиту методами POST, PUT і DELETE. Якщо дані не пройшли валідацію, сервер у секції “data” повертає звіт про те, які саме параметри невалідні й чому.

Протокол HTTP підтримує набагато більше число різних статус-кодів на всі випадки життя, але на практиці вони використовуються рідко і в контексті web API не мають практичної користі. Мені не доводилося виходити за межі вищепереліченого списку винятків.

Запити

Отримання елементів колекції

Одним з найчастотніших запитів є запит на отримання елементів колекції. Інформаційні стрічки, списки товарів, різні інформаційні й статистичні таблиці та багато чого іншого клієнтський додаток відображає за допомогою звернення до колекційних ресурсів. Для здійснення цього запиту ми звертаємося до колекції, використовуючи метод GET і передаючи в рядку запиту додаткові параметри. Як відповідь ми очікуємо отримати масив однорідних елементів колекції та інформацію, необхідну для пагінації – підвантаження продовження списку або ж конкретної його сторінки. Вміст вибірки може бути особливим способом обмежено і відсортовано за допомогою передачі додаткових параметрів. Про них і піде мова далі.

Посторінкова навігація

page – параметр вказує на те, яка сторінка має бути відображена. Якщо цей параметр не переданий, то відображається перша сторінка. З першої ж успішної відповіді сервера буде ясно, скільки сторінок має колекція за поточних параметрів фільтрації. Якщо значення перевищує максимальне число сторінок, то найрозумніше повернути помилку 404 Not found.

GET /api/v1/news?page=1

perPage – вказує на бажане число елементів на сторінці. Як правило, API має власне значення за умовчанням, яке повертає як поле perPage в секції pagination, але у ряді випадків дозволяє збільшувати це значення до розумних меж, надавши максимальне значення maxPerPage:

GET /api/v1/news?perPage=100

Сортування результатів

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

sortBy – є декілька підходів до передачі даних про складне сортування в GET параметрах. Тут необхідно чітко вказати порядок сортування і напрям.

У деяких API це пропонується зробити у вигляді рядка:

GET /api/v1/products?sortBy=name.desc,price.asc

В інших варіантах пропонується використати масив:

GET /api/v1/products?
sortBy[0][field]=name&
sortBy[0][direction]=desc&
sortBy[1][field]=price&
sortBy[1][direction]=asc

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

Фільтрація за верхньою і нижньою межею з використанням операторів порівняння from (більше або дорівнює), higher (більше), to (менше або дорівнює), lower (менше). Застосовується до полів, значення яких піддаються ранжуванню.

GET /api/v1/products?price[from]=500&price[to]=1000

Фільтрація за декількома можливими значеннями зі списку. Застосовується до полів, набір можливих значень яких обмежений, наприклад, фільтр за декількома статусами:

GET /api/v1/products?status[]=1&status[]=2

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

GET /api/v1/users?name[like]=JohnGET /api/v1/products?code[like]=123

Іменовані фільтри

У деяких випадках, коли певні набори фільтраційних параметрів часто вживані й передбачені системою як щось цілісне, особливо якщо зачіпають внутрішню, часто складну механіку формування вибірки, доцільно згрупувати їх у так звані “іменовані фільтри”. Достатньо передати в запиті ім’я фільтру, і система побудує вибірку автоматично.

GET /api/v1/products?filters[]=recommended

Іменовані фільтри можуть мати свої параметри.

GET /api/v1/products?filters[recommended]=kidds

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


За переклад матеріалу висловлюємо вдячність Міжнародній IT-компанії Noveo.

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *