company_banner

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



    Разработка заняла примерно 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<T: Decodable>: 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<EventMessageType> = 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<T: Decodable>() 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<T: CallRequest>(
        request: T,
        completion: @escaping (Result<T.ResultType, Error>) -> Void
    )
    func subscribe<T: EventRequest>(
        request: T,
        completion: @escaping (Result<UUID, Error>) -> Void,
        onEvent: @escaping (Result<T.ResultType, Error>) -> Void
    )
    func unsubscribe(
        id: UUID,
        completion: @escaping (Result<Void, Error>) -> Void
    )
    func publish<T: PublishRequest>(
        request: T,
        completion: @escaping (Result<Void, Error>) -> 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<T: PublishRequest>(request: T, completion: @escaping (Result<Void, Error>) -> 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<T>(message: WampMessageEncodable, with completion: @escaping (Result<T, Error>) -> 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 — об этом в другой раз.
    FunCorp
    Разработка развлекательных сервисов

    Комментарии 18

      +5

      Правильно что не стали испольpовать socket.io Так как рано или позно столкнулдись бы с двумя проблемами: 1) пропуск сообщений 2) дублирование сообощений. Подробности здесь https://habr.com/ru/post/469315/
      WAMP — к сожалению — также не решает эти вопросы. Пэтому для чатов лучше использовать что-то вроде MQTT.


      Кстати, какой брокер использовали — если autobahn — то еще есть у него ограничения по количеству подключенных клиентов одновременно.

        +2
        Спасибо за ссылку, а реализация брокера у нас полностью своя.)
          0

          Ну насколько я помню, autobahn — это только клиент. А роутер — у них Crossbar.io. Это всё на питоне :)
          А ещё есть Wiola например :) на nginx+lua или openresty. В общем вариантов много!

            0

            В списке на сайте там много чего есть. Даже несколько проектов на erlang которые теоретически могут выдерживать миллионы коннектов. Но увы кроме crossbar и возможно одного брокера на go там все практически нерабочее. В частности и wiola. Во всяком случае если и рабочее то сложно разворачиваемое. Так что вариантов не так много как может показаться по прочтении списка брокеров на сайте протокола

              0

              Ммм… А если не секрет — чем Wiola показалась сложна в разворачивании? Это же просто openresty + redis.
              yum install openresty redis + копипаст конфига.
              А для старта — даже есть докер-образ.


              Мне просто, как автору Wiola это будет интересно узнать :)

                +1

                Вобщем то я ее развернул. Хотя были проболемы с messagepack из за несовместимости уже не помню каких версий библиотек. Если есть Docker это уже проще. Однако то на чем я застопорился окончательно это клиент. Он так и не реагировал на сервер. Может быть если был в проекте какой то пример готовый к работе было бы не ного проще разобраться.


                Возможно одна из причин что я выполнял не yum install а apt-get install

                  0

                  Пробовал тогда кстати с autobahn клиентом. Работает ли с ним wiola или нужно брать тот клиент на который ссылка в readme wiola?

                    +1

                    https://gist.github.com/haizaar/208e4c63954db1cbc3aa1de4b8e5951f нашел кстати вариант. Жаль что его не была когда я пробовал с этим всем разобраться. WAMP по своему потенциалу технология которая может потеснить все эти к8с. Ей бы еще обзавестись средствами деплоя сервисов и проверки состояния и чуть больше маркетинга.

                      +1

                      Еще раз попробовал запустить си стему с образом докера из примера. Результат такой что RPC заработало без проблем а publish/subscribe не отвечает. Пробовал на двух клиентах wampy и autobahn

                        +1

                        Хм… Ну это очень странно… Разницы никакой нет. Надо будет глянуть. Потому что я давно уже не залезал в wiola, да и wampy тоже :) но они точно оба работают :)

                          0

                          Тут получилась вот какая штука. Закопипастил пример и там было из-за асинхронности работы вызов подписки после вызова публикации. Все работет.

                            0

                            Но все равно есть вопрос. Как выяснилось что подписка работает только в том случае если подписка и публикация были в разных сессиях сделаны (проверял двумя клиентами, наверное именно на этом моменте в свое время застрял. Сегодня чисто случайно попробовал с разных коннекшинов подписаться и публиковаться). Если подписка от одного объекта идет — то не работает. Вобщем-то достаточно искусственный случай когда подписчик и публишер это один объект. Но во многих примерах это так написано. То есть протокол, наверное, явно не запрещает и публиковать и подписываться с одной сессии.

                              +1

                              Это всё описано в протоколе :) По умолчанию да, событие не публикуется в сессию инициатор. Ибо это довольно специфичный кейс. Но в Advanced Profile есть возможность указать, что прислать сообещение и самому себе. И тогда получишь.

                    0
                    WAMP — к сожалению — также не решает эти вопросы. Пэтому для чатов лучше использовать что-то вроде MQTT.

                    Ну там есть подтверждение доставки в Advanced Profile. Это конечно не прям тоже самое, что гарантированная доствка в каком-ньть RabbitMQ, но всё же

                      0

                      Подтверждение есть и в socket.io. Результат от этого нулевой. Так как все стороны должны реализовать на уровне приложения по факту тот же mqtt только это практически невозможно. Потому что на уровне протокола работает математика строгая. А на уровне приложения уже зависит от подробностей.

                    +1
                    Скажите вы не рассматривали кросс-платформенное решение для бизнес логики? Возможно на нативе (c/c++) есть библиотеки где нет проблем с которым вы столкнулись. Хотя если у вас 80% это UI логика, то, конечно, не стоит так заморачиваться. Спасибо, было интересно почитать )
                      +2
                      Не рассматривали, так как, действительно, бОльшая часть логики связана с подготовкой и отображением данных.
                      +2
                      В комментах была речь о питоне, тогда кину линк channels.readthedocs.io/en/stable

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое