Pull to refresh

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

Development for iOS *Swift *
Tutorial

Сетевое ядро как часть приложения


Для начала немного поясню, о чем пойдет речь в данной статье. Сейчас большинство мобильных приложений, на мой взгляд, являются клиент-серверными. Это означает, что они содержат в составе кода сетевое ядро, отвечающее за пересылку запросов и получение ответов от сервера. Причем речь вовсе не о сетевых библиотеках, которые берут на себя ответственность по «низкоуровневому» управлению запросами вроде пересылки 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 headers list
        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 cервера.

Во второй части данной статьи мы рассмотрим, как применить созданное сетевое ядро в проекте, чтобы максимально использовать преимущества предложенной архитектуры.
Tags:
Hubs:
Total votes 11: ↑8 and ↓3 +5
Views 15K
Comments Comments 32