Архітектура мережевого ядра в iOS-додатку на Swift 3. Частина 1

Мережеве ядро як частина програми
Для початку трохи поясню, про що піде мова в даній статті. Зараз більшість мобільних додатків, на мій погляд, є клієнт-серверними. Це означає, що вони містять у складі коду мережеве ядро, яке відповідає за пересилання запитів та отримання відповідей від сервера. Причому мова зовсім не про мережевих бібліотеках, які беруть на себе відповідальність за «низкорівневому» управління запитами на зразок пересилання REST-запитів, побудови multipart-тіла, роботи з сокетами, веб-сокетами, і так далі. Мова йде про додаткову обв'язку, яка дозволяє управляти запитами, відповідями і даними стану, характерними конкретно для вашого сервера. Саме у варіантах реалізації цієї обв'язки і укладені основні проблеми мережного рівня в багатьох мобільних проектах, з якими мені доводилося працювати.

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

Подробиці під катом.

Клієнт-серверне взаємодія
В процесі роботи доводиться розбирати безліч самих різних проектів, так чи інакше взаємодіють з серверами, побудованими за REST-протоколу. І в більшості цих проектів спостерігається картина з розряду «хто в ліс, хто по дрова», оскільки мережеве ядро скрізь реалізується по-різному (втім, як і інші архітектурні частини програми). Особливо погано справи йдуть, коли в одному проекті видна рука декількох розробників, які змінювали один одного (та ще й в умовах стислих термінів, як правило). У таких випадках нерідко виходить «ядро» досить моторошного виду, чи не володіє власним інтелектом. Спробуємо вберегтися від таких проблем за допомогою запропонованого підходу.

З чого почнемо?
  • Для реалізації будемо використовувати мову Swift версії 3.0.

  • Умовимося, що ми будемо використовувати гіпотетичний REST-сервер, що працює тільки з GET і POST-запитів. В якості відповідей він буде нам повертати або JSON, або якісь дані (на жаль, далеко не всі сервери мають уніфіковану форму відповіді, так що доводиться передбачати різні варіанти).

  • Також умовимося, що для «низькорівневого» мережевого спілкування нам не потрібна окрема мережева бібліотека, ми напишемо цю частину самі (тим більше, на момент написання цієї статті мережевих бібліотек, оновлених до версії мови 3.0, просто немає).

  • Пропонований підхід є урізаною версією архітектури SOA (Service-Oriented Architecture), з віддаленими частинами, що не використовуються у невеликих проектах (зразок окремої підсистеми кешування), адаптованої під умови суворої типізації мови Swift.

  • Для реалізації нам буде достатньо Xcode Playground, окремий проект не обов'язковий.
Приступимо. Для початку трохи про самому процесі. Опишемо покрокову схему роботи клієнтського мобільного додатку з сервером:

  • Користувач робить дію, що вимагає взаємодії з сервером.

  • Додаток через мережеве ядро відправляє GET або POST-запит на сервер, задаючи певний URL-адресу, набір заголовків, тіло запиту, таймаут, кеш-політику та інші параметри.

  • Сервер обробляє запит і надсилає (або не надсилає, якщо щось пішло не так з каналом зв'язку) з додатком відповідь.

  • Додаток аналізує отриману інформацію, оновлює дані стану і користувальницький інтерфейс.
А в чому тут складність?
І правда, поки все звучить досить просто. Однак насправді ці 4 простих пункту містять у собі додаткові кроки, які потребують додаткових трудовитрат, і от саме тут починається різноманітність в плані реалізації цих самих проміжних кроків. Трохи розширимо нашу послідовність, доповнивши її питаннями, що виникають в процесі роботи:

  • Користувач робить дію, що вимагає взаємодії з сервером.

    • Залежить дія користувача від даних стану, пов'язаних з сервером? Наприклад, потребує дію наявності авторизаційного токена? Якщо так, то де його зберігати, як перевіряти?

    • Серверне API може складатися з кількох «точок доступу», наприклад, вузол авторизації, вузол роботи з користувацькими даними, і так далі. Непогано було б якось розділити ці дії на групи, щоб не звалювати весь код в одну велику простирадло.

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

  • Додаток через мережеве ядро відправляє GET або POST-запит на сервер.

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

    • Кожний запит може мати обов'язковий набір заголовків, а також додаткові заголовки, потрібні для конкретного запиту, включаючи типи відправлених і прийнятих даних.

    • Запит може бути запущений в особливій сесії (мова про URLSession), зі специфічним таймаутом і політикою кешування даних.

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

  • Сервер обробляє запит і надсилає (або не надсилає, якщо щось пішло не так з каналом зв'язку) з додатком відповідь.

    • якщо вже отримання відповіді — подія нестабільне, то нам дійсно потрібно вміти обробляти не тільки відповіді, але і генеруються помилки. А враховуючи, що серверні API часто не мають скільки-небудь уніфікованої структурою відповідей і видачі помилок, варто приготуватися до отримання самих різних форматів помилок, включаючи системні.

    • Оскільки в успішному відповіді (в залежності від реалізації серверного API) нам може прийти як сам успішний відповідь, так і відмова сервера в обробці операції (наприклад, якщо ми забули вказати авторизаційний токен для запиту, закритого користувача авторизацією), то нам потрібно вміти обробляти як успішні відповіді, так і «успішні помилки». При цьому, самі обробники бажано розміщувати у вигляді самодостатніх окремих сутностей в коді, які можна легко аналізувати і супроводжувати.

  • Додаток аналізує отриману інформацію, оновлює дані стану і користувальницький інтерфейс.

    • пункт знову повертає нас до питання зберігання даних стану. Ми повинні вибрати дані з відповіді сервера, які необхідні нам для відображення інтерфейсу користувача, а також дані, які можуть знадобитися при подальших запитах до сервера, обробити їх і зберегти у зручному для нас вигляді.
Здавалося б, досить нескладний набір питань, але з-за їх кількості та різноманітності доступного в мові інструментарію реалізація всіх цих етапів від проекту до проекту може відрізнятися до невпізнання. До речі кажучи, при невеликій модифікації пропонований підхід може застосовуватися і в Objective-C (як мовою з строгою типізацією), але це виходить за рамки даної статті.

Проектування
Для вирішення всіх наведених вище питань нам знадобляться кілька сутностей:

  • Інформація про «успішну помилку». Ця сутність забезпечуватиме отримання і зберігання інформації про «успішну помилку», або «відмову сервера», як я найчастіше її називаю — наприклад, про помилку авторизації.

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

  • Асинхронна мережева завдання. Власне сам асинхронний таск, зберігає свій стан, а також вхідні і вихідні параметри, включаючи, зрозуміло, сам серверний запит.

  • Пул завдань. Клас займається відправленням з урахуванням активних асинхронних мережевих завдань. Також зберігає загальні дані по серверному API, начебто базового URL, стандартного набору заголовків і так далі.

  • Протокол обробки відповіді. Даний протокол забезпечить нам можливість створювати строго типізовані сутності («парсери», або обробники), які зможуть приймати на вхід потік даних (у тому числі форматований JSON) і трансформувати його в зрозумілі додатком структури, або моделі даних (про них трохи нижче).

  • Моделі даних. Структури даних, зрозумілі і зручні для застосування. Є віддзеркаленням серверних даних в адаптованому для застосування вигляді.

  • «Парсери», або обробники відповіді. Сутності, перетворюють «сирі» дані в моделі даних, і навпаки. Реалізують протокол обробки відповіді.

  • Сервіс, або вузол доступу до даних. Інкапсулює управління групою логічно пов'язаних операцій. Наприклад, AuthService, UserService, ProfileService і так далі.
Якщо вам здається, що тут дуже багато ланок — спочатку я теж так думав. Поки не спробував кілька проектів з відсутністю спроектованого мережевого ядра в принципі. Без чіткої структури вже після реалізації 5-10 запитів до сервера код починає швидко перетворюватися в кашу, якщо залишити його невпорядкованим. Знову-таки, я пропоную лише один з підходів, і в даний час саме він найбільш зручний для мене у роботі над проектами.

Для наочності я відобразив весь процес на схемі (зображення кликабельно):



Реалізація ядра
Поїхали. Створимо Playground і підемо прямо по кроках з попереднього розділу. Плюс, припустимо, що в більшості випадків мій сервер повертає мені JSON. Який парсер JSON використовувати — це на ваш розсуд, на момент написання статті, знову-таки, адаптованих під третю версію мови бібліотек ще не було, тому я використовував самописний парсер GJSON.

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

Інформація про помилку
final class ResponseError {
let code: NSNumber?
let message: String?

// MARK: - Root

init(json: Any?) {
if let obj = GJSON(json) {
code = obj.number("code")
message = obj.string("message")
}
else {
code = nil
message = nil
}
}
}

Інформація про відповіді
Трохи більш докладний клас, принцип той же. В даному випадку він також відповідає відповіді, повертається сервером, це бінарні дані на вхід, код відповіді, опціональне повідомлення + распарсенная помилка:

Інформація про відповіді
final class Response {
let data: Any?
let code: NSNumber?
let message: String?
let error: ResponseError?

// MARK: - Root

init?(json: Any?) {
guard let obj = GJSON(json) else { return nil }

code = obj.number("code")
message = obj.string("message")
data = json
error = ResponseError(json: json)
}
}

Для зручності у вашому проекті, якщо ви працюєте гарантовано з JSON у повертаються бінарних даних, то можна розширити цей клас через extension, наприклад, додавши йому поле, повертає json-парсер (як приклад):

Розширення для зручності
extension Response {
var parser: GJSON? {
return GJSON(data)
}
}

Асинхронна мережева завдання
Ось це вже куди більш цікава річ. Призначення цього класу полягає в зберіганні всіх віддаються даних, всіх прийнятих даних і свого стану (завершено/скасовано). Також уміє відправляти себе в мережу і викликати callback після завершення роботи. При отриманні відповіді від сервера записує всі отримані дані, в тому числі робить спробу трансформації вхідних даних JSON-об'єкт, якщо це можливо, для спрощення подальшої обробки. Також, оскільки в подальшому набір таких завдань ми будемо використовувати всередині безлічі Set, нам знадобиться реалізувати кілька системних протоколів.

Асинхронна мережева завдання
typealias DataTaskCallback = (DataTask) -> ()

final class DataTask: Hashable, Equatable {
let taskId: String
let request: URLRequest
let session: URLSession

private(set) var responseObject: Response?
private(set) var response: URLResponse?
private(set) var error: Error?
private(set) var isCancelled = false
private(set) var isCompleted = false

private var task: URLSessionDataTask?

// MARK: - Root

init(taskId: String, request: URLRequest, session: URLSession = URLSession.shared) {
self.taskId = taskId
self.request = request
self.session = session
}

// MARK: - Controls

func start(callback: DataTaskCallback?) {
task = session.dataTask(with: request) { [unowned self] (data, response, error) in
if self.isCancelled {
return
}
self.isCompleted = true
// transform if possible
var wrappedData: Any? = data

if data != nil {
if let json = try? JSONSerialization.jsonObject(with data!, options: .allowFragments) {
wrappedData = json
}
}
// parse
self.responseObject = Response(json: wrappedData)
self.error = error
// callback
callback?(self)
}
task?.resume()
}

func cancel(callback: DataTaskCallback?) {
if task != nil && !isCompleted && !isCancelled {
isCancelled = true
task?.cancel()
// callback
callback?(self)
}
}

// MARK: - Equatable

static func ==(lhs: DataTask, rhs: DataTask) -> Bool {
return lhs.taskId == rhs.taskId
}

// MARK: - Hashable

var hashValue: Int {
return taskId.hashValue
}
}

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

Пул завдань
final class TaskPool {
static let instance = TaskPool() // singleton

let session = URLSession.shared // default session
let baseURLString = "https://myserver.com/api/v1/" // default base URL
var defaultHeaders: [String): String] { // default list headers
return ["Content-Type": "application/json"]
}

private(set) var activeTasks = Set<DataTask>()

// MARK: - Root

private init() { } // forbid multi-instantiation

// MARK: - Controls

func send(task: DataTask, callback: DataTaskCallback?) {
// check for existing task
if taskById(task.taskId) != nil {
print("task with id \"\(task.taskId)\" is already active.")
return
}
// start
activeTasks.insert(task)
print("start task \"\(task.taskId)\". URL: \(task.request.url!.absoluteString)")

task.start { [unowned self] (task) in
self.activeTasks.remove(task)
print("task finished \"\(task.taskId)\". URL: \(task.request.url!.absoluteString)")
callback?(task)
}
}

func send(taskId: String, request: URLRequest, callback: DataTaskCallback?) {
let task = DataTask(taskId: taskId, request: request, session: session)
send(task: task, callback: callback)
}

func taskById(_ taskId: String) -> DataTask? {
return activeTasks.first(where: { (task) -> Bool in
return task.taskId == taskId
})
}
}

Протокол
І завершальна частина, що відноситься безпосередньо до мережного ядру, — обробка відповідей. Сувора типізація мови вносить свої корективи. Ми хочемо, щоб наші класи-парсери обробляли дані і віддавали нам певний тип даних, а не який-небудь Any?.. Для цих цілей зробимо невеликий генерик-протокол, який надалі будемо реалізовувати:

Протокол
protocol JSONParser {
associatedtype ModelType
func parse(json: Any?) -> ModelType? 
}

Що далі?
Власне, на цьому створення мережевого ядра завершено. Його можна спокійно переносити між проектами, тільки злегка підрихтовуючи структури інформації про відповіді і помилки, а також адреса API сервера.

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

0 коментарів

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