Чаты на вебсокетах в iOS, если у вас WAMP

tsjp7wzihxkk57gpj4tcxfm-lr4.jpeg

Разработка заняла примерно 9 месяцев, а я занимался реализацией клиент-серверного общения по сокету для iOS. Особенности нашей ситуации:

  1. Поддержка старых версий iOS, где нативных методов для общения по сокетам ещё не было — пришлось искать рабочую библиотеку и фиксить баги.
  2. Протокол WAMP на бэкенде — предстояло научить клиент декодировать any, декодировать протоколы и создать объект, который отвечает за отправку и приём сообщений.


Примечание: описанные ниже способы декодирования, можно применить и в других задачах.

Поиск живой библиотеки


Нативные методы для работы с вебсокетами были еще в iOS 11, а с версии iOS 13 они стали даже удобными. Но мы хотели сделать рабочий чат для всех с версии iOS 9, а уже потом потихоньку убирать поддержку. На это было несколько причин, в том числе и то, что у наших пользователей в США довольно много старых версий операционной системы.

Писать поддержку вебсокетов, начиная с iOS 9, самостоятельно было бы слишком долго, поэтому решил искать готовую библиотеку. Выбирал по популярности и количеству заведённых тикетов, в итоге остановился на Starscream. Но и она оказалась с проблемами — у библиотеки до сих пор висят десятки открытых тикетов и, по сравнению с нативным решением в iOS 13, она очень объёмная.

Смотрел ещё в сторону Socket.IO — самую большую из конкурентов — там меньше форков и лайков, но при этом в несколько раз больше незакрытых вопросов.

Starscream, в свою очередь, вызывала баги, и её пришлось форкать. Вот две основные проблемы, с которыми мы столкнулись (они обнаружились не сразу, а только в процессе тестирования, когда появилось больше данных):

  1. Отсутствие события на таймаут. Из-за этого были сложности с переподключением к сокету после восстановления интернет-соединения.
  2. Неверные очереди вызова коллбеков.


Ещё несколько багов:

  • Коллбек не получал ошибку в ряде случаев.
  • В некоторых местах был возврат функций без вызова коллбека по неизвестной причине.
  • Ошибка компиляции на XCode 12. Прямо в процессе разработки вышел новый XCode, а библиотека не обновилась. Мы немного подождали, но в итоге пришлось фиксить самостоятельно каст к NSString.


Работа с WAMP


Как отправлять и получать данные было понятно, и пошла более высокоуровневая работа. Бэкенд выбрал протокол общения клиента с сервером WAMP, но для клиента он не работает из коробки, пришлось допиливать.

Минусы протокола:

— Кодирование всех типов событий в числах (PUBLISH — это 16, SUBSCRIBE — 32 и так далее), что сильно усложняет чтение логов для разработки и QA (пойди, сразу догадайся, что значит прилетевшее сообщение [33,11,5862354]).

— Механизм подписок на события (например, новые сообщения в чат или обновление количества участников) реализован через получение от бэкенда уникального id подписки, который ещё надо где-то хранить и ни в коем случае не терять во избежание утечек.

Плюсы:

+ Радует, что протокол за вас предусматривает всё или почти всё. Это облегчает взаимодействие разработчиков клиентской части и бэкенда.

Чтобы довести его до ума, передо мной стояли три основные задачи:

  1. Научиться кодировать и декодировать any. У нас клиент-серверное общение в JSON-формате и сервер присылает/получает сообщения, которые стандартно не декодируются.
  2. Кодировать и декодировать протоколы.
  3. Создать объект, который будет отвечать за отправку и приём сообщений.


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

Рассмотрим на примерах. Допустим, мы получаем от сервера сообщение:

[36, 1, 2, {}]


Расшифровываем согласно этому документу и получаем:

[EVENT, SUBSCRIBED.Subscription|id, PUBLISHED.Publication|id, Details|dict]


Как декодировать


Сначала преобразуем JSON в [Any], для этого написано расширение для UnkeyedDecodingContainer. Так выглядит использование:

var container = try decoder.unkeyedContainer()
var array = try container.decode([Any].self)


Из массива нужно убрать первый элемент, определить по нему тип сообщения (в данном случае это EventMessage) и преобразовать его в decoder для декодирования уже готовым инициализатором протокола Decodable.

Первый элемент массива всегда является числом, которое нужно связать с конкретным типом сообщения. Так выглядит enum для связи числа и типа:

enum WampMessageDecodableType: Int, Decodable, CaseIterable {
    case event = 36
    
    private var messageType: WampMessageDecodable.Type {
        switch self {
        case .event: return EventMessage.self
        }
    }
    
    var factory: (Decoder) throws -> WampMessageDecodable {
        messageType.init(from:)
    }
}


Преобразуем число в соответствующий ему тип:

guard let typeValue = array.removeFirst() as? Int,
              let messageType = WampMessageDecodableType(rawValue: typeValue)
        else { throw Self.typeDecodingError }


Так как у массива нет ключей, а типы в нём разные, нужно преобразовать его в словарь, где ключом будет являться его индекс:

let data = try JSONSerialization.data(withJSONObject: array.indexToKeyDictionary)


Где indexToKeyDictionary кастомное расширение. Результат:

[1, 2, {}] => {0: 1, 1: 2,  3: {}}


Опишем расширение и структуру для получения декодера из Data:

struct DecoderHolder: Decodable {
    let decoder: Decoder

    init(from decoder: Decoder) throws {
        self.decoder = decoder
    }
}


extension JSONDecoder {
    func getDecoder(from data: Data) throws -> Decoder {
        try decode(DecoderHolder.self, from: data).decoder
    }
}


Теперь получаем декодер и декодируем сообщение:

let keyDecoder = try JSONDecoder.defaultDecoder.getDecoder(from: data)
message = try messageType.factory(keyDecoder)


Чтобы декодирование работало с целочисленными ключами, используем CodingKeys с типом Int и расширение:

extension CodingKey where Self: RawRepresentable, RawValue == Int {
    var stringValue: String {
        .init(intValue ?? .min)
    }
}


Теперь сообщения можно декодировать стандартным декодером и не описывать дополнительный маппинг. Так, например, выглядит WelcomeMessage:

struct WelcomeMessage: WampMessageDecodable {
    let sessionId: Int
    let details: WelcomeDetails
    
    enum CodingKeys: Int, CodingKey {
        case sessionId, details
    }
}


C EventMessage немного сложнее, так как внутри он также содержит протокол. Декодируем его через вспомогательную структуру:

struct TypeHolder: Decodable {
    let type: T
}


Так выглядит упрощённый EventMessage:

public protocol EventEntry: Decodable { }

struct EventMessage: WampMessageDecodable, SubscriptionIdProvidable {
    let event: EventEntry
    
    enum CodingKeys: Int, CodingKey {
        case event
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let superDecoder = try container.superDecoder(forKey: .event)
        let typeHolder: TypeHolder = try container.decode(forKey: .event)
        event = try typeHolder.type.factory(superDecoder)
    }
}


Где EventMessageType аналогичен WampMessageDecodableType:

enum EventMessageType: Int, Codable, CaseIterable {
    case chatListUpdate = 100
    
    private var messageType: EventEntry.Type {
        switch self {
        case .chatListUpdate: return ChatListUpdateEventEntry.self
        }
    }
    
    var factory: (Decoder) throws -> EventEntry {
        messageType.init(from:)
    }
}


Рассмотрим ещё один пример декодирования ResultMessage. Так выглядит сообщение:

[50, 7814135, {}, [], {"userid": 123, "karma": 10}]


Сложность в том, что ResultMessage не может быть дженериком, так как тип ответа неизвестен заранее, и в зависимости от конкретного запроса возвращаемое значение (объект по третьему индексу) будет отличаться.

Решение состоит в том, чтобы сохранить декодер и использовать его позже, когда тип будет определён.

struct ResultMessage: WampMessageDecodable, RequestIdProvidable {
    private var resultDecoder: Decoder
    
    enum CodingKeys: Int, CodingKey {
        case resultDecoder = 3
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        resultDecoder = try container.superDecoder(forKey: .resultDecoder)
    }
}

extension ResultMessage {
    func decodeResult() throws -> T {
        try T.init(from: resultDecoder)
    }
}


Кодирование происходит по схожим принципам и отличается незначительно.

Общение с сокетом


Объект WebsocketSessionImpl инкапсулирует в себе создание и разрыв соединения с сокетом, менеджмент получения и отправку сообщений, а также держит массив делегатов со слабыми ссылками. Когда делегаты деинициализируются, то соединение с сокетом закрывается с небольшой задержкой.

Подключение к сокету достаточно простое. Получаем handshake от первого делегата и коннектимся:

guard let request = firstDelegate?.handshakeRequest
else { return }

socket = .init(request: request)
socket?.delegate = self
socket?.connect()


WebsocketSessionImpl сохраняет коллбеки запросов для ивентов и подписок. И вызывает их при получении ответа.

Так выглядят сигнатуры главных методов общение с WebsocketSessionImpl:

func call(
    request: T,
    completion: @escaping (Result) -> Void
)
func subscribe(
    request: T,
    completion: @escaping (Result) -> Void,
    onEvent: @escaping (Result) -> Void
)
func unsubscribe(
    id: UUID,
    completion: @escaping (Result) -> Void
)
func publish(
    request: T,
    completion: @escaping (Result) -> Void
)


Call — похож на get-запрос; publish — это post; subscribe — подписка на событие (например, получение нового сообщение из чата); unsubscribe — отписка от события. Результатом всех этих методов является инициализация сообщения определённого типа и отправка его в JSON-формате.

При этом коллбеки под обёрткой сохраняются в словарь и ожидают ответа:

private var results: [RequestId: SaveableCompletion] = [:]


Аналогично для событий:

private var events: [SubscriptionEvent: SaveableCompletion] = [:]


Подписка только одна, а внутри приложения разные модули могут подписываться на одно и то же событие. Поэтому при наличии подписки создается её копия с другим коллбеком:

private struct SubscriptionEvent: Hashable {
    let id: Int
    let localId = UUID()
    let topic: TopicModel
    
    func copy() -> Self {
        .init(id: id, topic: topic)
    }
}


При получении текста из сокета, вызывается метод:

func handleText(_ text: String) {
    let data = try text.data(using: .utf8)
    let message = try JSONDecoder.defaultDecoder.decode(WampBaseDecodableMessage.self, from: data).message
    handleMessage(message)
}


Затем сообщение (в зависимости от типа) обрабатывается:

func handleMessage(_ message: WampMessageDecodable) {
    switch message {
    case _ as ChallengeMessage:
        sendAuthenticateMessage()
    case _ as WelcomeMessage:
        isConnected = true
    case let message as ErrorMessage:
        handleErrorMessage(message)
    case let message as RequestIdProvidable & WampMessageDecodable:
        informThatRecieved(message)
    case let message as SubscriptionIdProvidable & WampMessageDecodable:
        informSubscriber(message)
    default:
        break
    }
}


В качестве примера, отправка publish-сообщения:

public func publish(request: T, completion: @escaping (Result) -> Void) {
    let id = nextId
    results[id] = .init(respondTo: PublishedMessage.self, completion)
    let message = factory.publish(requestId: id, request: request)
    write(message: message, with: completion)
}


func write(message: WampMessageEncodable, with completion: @escaping (Result) -> Void)  {
        do {
        try writeToSocket(message: message, with: { error in
            guard let error = error else { return }
            completion(.failure(error))
        })
    } catch let error {
        completion(.failure(error))
    }
}

func writeToSocket(message: WampMessageEncodable, with completion: ((Error?) -> Void)?) throws {
    let baseMessage = try WampBaseEncodableMessage(message:message)
    let data = try JSONEncoder.defaultEncoder.encode(baseMessage)
    
    guard let text = String(data: data, encoding: .utf8)
    else { return }
    
    socket?.write(string: text, completion: completion)
}


Вместо заключения


Остальная реализация чата — это отдельная история, логика во многом переписывалась, а UI уже был написан, но всё это не касается сокета. Что же касается реализации клиент-серверного общения на Android с учетом WAMP — об этом в другой раз.

© Habrahabr.ru