Picture created by rawpixel.com
В этой публикации мы опустимся на уровень TCP, изучим сокеты и средства Core Foundation на примере разработки приложения для чата.
Приблизительное время чтения публикации: 25 минут.
Почему сокеты?
Вы можете удивиться: «А зачем мне идти на уровень ниже, чем URLSession?» Если вы достаточно сообразительны и не задаётесь этим вопросом, переходите сразу к следующему разделу.
Ответ для не столь сообразительных
Отличный вопрос! Дело в том, что использование URLSession основано на протоколе HTTP, то есть связь происходит в стиле запрос-отклик, примерно следующим образом:
Но что, если нам нужно, чтобы сервер мог по своей инициативе передавать данные вашему приложению? Здесь HTTP оказывается не у дел.
Конечно, мы можем непрерывно дергать сервер и смотреть, если там данные для нас (aka polling). Или мы можем быть более изощренными и использовать long-polling. Но все эти костыли слегка неуместны в данном случае.
В конце концов, зачем ограничивать себя парадигмой request-response, если она подходит к нашей задаче чуть меньше, чем никак?
В этом руководстве вы научитесь, как погрузиться на более низкий уровень абстракции и напрямую использовать СОКЕТЫ в приложении для чата.
Вместо того, чтобы проверять сервер на наличие новых сообщений, наше приложение будет использовать потоки (streams), остающиеся открытыми на протяжении чат-сессии.
- запросить с сервера какие-то данные в формате JSON
- получить эти данные, обработать, отобразить, etc.
Но что, если нам нужно, чтобы сервер мог по своей инициативе передавать данные вашему приложению? Здесь HTTP оказывается не у дел.
Конечно, мы можем непрерывно дергать сервер и смотреть, если там данные для нас (aka polling). Или мы можем быть более изощренными и использовать long-polling. Но все эти костыли слегка неуместны в данном случае.
В конце концов, зачем ограничивать себя парадигмой request-response, если она подходит к нашей задаче чуть меньше, чем никак?
В этом руководстве вы научитесь, как погрузиться на более низкий уровень абстракции и напрямую использовать СОКЕТЫ в приложении для чата.
Вместо того, чтобы проверять сервер на наличие новых сообщений, наше приложение будет использовать потоки (streams), остающиеся открытыми на протяжении чат-сессии.
Начинаем
Загрузите исходные материалы. Там есть макет приложения-клиента и простой сервер, написанный на Go.
Вам не придется писать на Go, но вам нужно будет запустить серверное приложение, чтобы приложения-клиенты могли к нему подключаться.
Запускаем серверное приложение
В исходных материалах есть как скомпилированное приложение, так и исходник. Если у вас здоровая паранойя и вы не доверяете чужому откомпилированному коду, вы можете самостоятельно скомпилировать исходники.
Если же вы отважны, то откройте Terminal, перейдите в каталог с загруженными материалами и выполните команду:
sudo ./server
Когда появится запрос, введите пароль. После этого вы должны увидеть сообщение
Listening on 127.0.0.1:80.
Замечание: серверное приложение запускается в привилегированном режиме (команда «sudo»), потому что прослушивает порт 80. Все порты с номерами меньше, чем 1024, требуют специального доступа.
Ваш чат-сервер готов! Можете перейти к следующему разделу.
Если же вы хотите самостоятельно скомпилировать исходники сервера,
то в этом случае вам необходимо при помощи Homebrew установить Go.
Если у вас нет и Homebrew, то нужно установить вначале его. Откройте Terminal и вставьте туда следующую строку:
Затем используйте эту команду для установки Go:
По окончании перейдите в каталог с загруженными исходными материалами и скомпилируйте исходники серверного приложения:
Наконец, можно запустить сервер командой, приведенной в начале этого раздела.
Если у вас нет и Homebrew, то нужно установить вначале его. Откройте Terminal и вставьте туда следующую строку:
/usr/bin/ruby -e \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
Затем используйте эту команду для установки Go:
brew install go
По окончании перейдите в каталог с загруженными исходными материалами и скомпилируйте исходники серверного приложения:
go build server.go
Наконец, можно запустить сервер командой, приведенной в начале этого раздела.
Смотрим, что у нас в клиенте
Теперь откройте проект DogeChat, откомпилируйте его и посмотрите, что там есть.
Как видим, DogeChat сейчас позволяет ввести имя пользователя и перейти в раздел собственно чата.
Похоже, что разработчик, занимавшийся этим проектом, понятия не имел, как сделать чат. Так что всё, что у нас есть — это базовый UI и навигация. Сетевой слой будем писать мы. Ура!
Создаём комнату для чата
Чтобы перейти непосредственно к разработке, перейдите к ChatRoomViewController.swift. Это вьюконтроллер, который может получать введенный пользователем текст и отображать получаемые сообщения в тейблвью.
Так как у нас есть ChatRoomViewController, есть смысл разработать класс ChatRoom, который будет заниматься всей черновой работой.
Давайте подумаем о том, что будет обеспечивать новый класс:
- открытие соединения с приложением-сервером;
- подключение пользователя с заданным им именем к чату;
- передачу и получение сообщений;
- закрытие соединения по окончании.
Теперь, когда мы знаем, чего хотим от этого класса, жмём Command-N, выбираем Swift File и обзываем его ChatRoom.
Создаём потоки ввода/вывода
Заменим содержимое ChatRoom.swift вот этим:
import UIKit
class ChatRoom: NSObject {
//1
var inputStream: InputStream!
var outputStream: OutputStream!
//2
var username = ""
//3
let maxReadLength = 4096
}
Тут мы определяем класс ChatRoom и объявляем нужные нам свойства.
- Сначала мы определяем потоки ввода/вывода. Использование их парой позволит нам создать соединение на сокете между приложением и чат-сервером. Разумеется, мы будем отправлять сообщения, используя поток вывода, а получать — при помощи потока ввода.
- Далее мы определяем имя пользователя.
- И, наконец, мы определяем переменную maxReadLength, которой ограничиваем максимальную длину отдельного сообщения.
Теперь перейдите к файлу ChatRoomViewController.swift и добавьте эту строчку к списку его свойств:
let chatRoom = ChatRoom()
Теперь, когда мы создали базовую структуру класса, пора заняться первым из намеченных дел: открытием соединения между приложением и сервером.
Открываем соединение
Возвращаемся к ChatRoom.swift и за определениями свойств добавляем этот метод:
func setupNetworkCommunication() {
// 1
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
// 2
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault,
"localhost" as CFString,
80,
&readStream,
&writeStream)
}
Вот что мы тут делаем:
- сначала мы определяем две переменных для потоков сокета без использования автоматического управления памятью
- затем мы, используя эти самые переменные, создаем непосредственно потоки, привязанные к хосту и номеру порта.
У функции четыре аргумента. Первый — это тип распределителя памяти, который мы будем использовать при инициализации потоков. Вы должны использовать kCFAllocatorDefault, хотя есть и другие возможные варианты, в случае, если вы хотите изменить поведение потоков.
Примечание переводчика
Документация к функции CFStreamCreatePairWithSocketToHost говорит: используйте NULL или kCFAllocatorDefault. А описание kCFAllocatorDefault говорит, что это синоним NULL. Круг замкнулся!
Затем мы задаем имя хоста. В нашем случае мы подключаемся к локальному серверу. Если у вас сервер расположен в другом месте, то тут можно задать его IP адрес.
Затем номер порта, которые прослушивает сервер.
Наконец, мы передаем указатели на наши потоки ввода/вывода, чтобы функция могла их инициализировать и подключить к создаваемым ей потокам.
Теперь, когда у нас есть инициализированные потоки, мы можем сохранить ссылки на них, добавив эти строки в конце метода setupNetworkCommunication():
inputStream = readStream!.takeRetainedValue()
outputStream = writeStream!.takeRetainedValue()
Использование takeRetainedValue() применительно к неуправляемому объекту позволяет нам сохранить ссылку на него, и, одновременно, избежать в дальнейшем утечек памяти. Теперь мы можем использовать наши потоки где захотим.
Теперь нам нужно добавить эти потоки к циклу выполнения (run loop), чтобы наше приложение корректно отрабатывало сетевые события. Для этого добавим эти две строчки в конце setupNetworkCommunication():
inputStream.schedule(in: .current, forMode: .common)
outputStream.schedule(in: .current, forMode: .common)
Наконец пора поднять паруса! Чтобы начать, добавьте это в самом конце метода setupNetworkCommunication():
inputStream.open()
outputStream.open()
Теперь у нас есть открытое соединение между нашим клиентом и серверным приложением.
Мы можем скомпилировать и запустить наше приложение, но никаких изменений вы пока не увидите, потому что пока мы ничего не делаем с нашим соединением клиент-сервер.
Подключаемся к чату
Теперь, когда у нас есть установленное соединение с сервером, самое время начать с этим что-то делать! В случае чата, вам нужно сначала представиться, а затем вы сможете слать собеседникам сообщения.
Это приводит нас к важному выводу: так как у нас два типа сообщений, нам нужно как-то их различать.
Протокол чата
Одно из преимуществ использования уровня TCP состоит в том, что мы можем определить свой собственный «протокол» для взаимодействия.
Если бы мы использовали HTTP, то нам нужно было бы использовать эти разные словечки GET, PUT, PATCH. Нам нужно было бы формировать URL'ы и использовать для них правильные хедеры и все такое.
У нас же всего два типа сообщений. Мы будем слать
iam:Luke
чтобы войти в чат и представиться.
И мы будем слать
msg:Hey, how goes it, man?
чтобы отправить сообщение всем респондентам в чате.
Это очень просто, но абсолютно несекюрно, поэтому не используйте подобный способ в критически важных проектах.
Теперь мы знаем, чего ожидает наш сервер и можем написать метод в классе ChatRoom, который позволит пользователю подключиться к чату. Единственный аргумент — это «ник» пользователя.
Добавьте этот метод внутри ChatRoom.swift:
func joinChat(username: String) {
//1
let data = "iam:\(username)".data(using: .utf8)!
//2
self.username = username
//3
_ = data.withUnsafeBytes {
guard let pointer = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
print("Error joining chat")
return
}
//4
outputStream.write(pointer, maxLength: data.count)
}
}
- Сначала мы формируем наше сообщение, используя наш собственный «протокол»
- Сохраняем имя для дальнейшего использования.
- withUnsafeBytes(_:) обеспечивает удобный способ работы с небезопасным указателем внутри замыкания.
- Наконец мы отправляем наше сообщение в выходной поток. Это может выглядеть сложнее, чем можно было бы предположить, однако write(_:maxLength:) использует небезопасный указатель, созданный на предыдущем этапе.
Теперь наш метод готов, откроем ChatRoomViewController.swift и добавим вызов этого метода в конце viewWillAppear(_:).
chatRoom.joinChat(username: username)
Теперь откомпилируйте и запустите приложение. Введите ваш ник и тапните на return чтобы увидеть…
...что опять ничего не изменилось!
Постойте, все в порядке! Перейдите к окну терминала. Там вы увидите сообщение Вася has joined или что-то в этом роде, если ваше имя не Вася.
Это здорово, но хорошо бы иметь индикацию успешного подключения на экране своего телефона.
Реагирование на входящие сообщения
Сервер рассылает сообщения о присоединении клиента всем, кто есть в чате, в том числе и вам. К счастью, в нашем приложении уже есть все для отображения любых входящих сообщений в виде ячеек в таблице сообщений в ChatRoomViewController.
Всё, что вам нужно сделать — использовать inputStream, чтобы «отловить» эти сообщения, преобразовать их в экземпляры класса Message, и передать их таблице для отображения.
Чтобы иметь возможность реагировать на входящие сообщения, вам необходимо, чтобы ChatRoom соответствовал протоколу StreamDelegate.
Для этого внизу файла ChatRoom.swift добавьте это расширение:
extension ChatRoom: StreamDelegate {
}
Теперь объявим того, кто станет делегатом inputStream‘а.
Добавьте эту строчку в метод setupNetworkCommunication() прямо перед вызовами schedule(in:forMode:):
inputStream.delegate = self
Теперь добавим в расширение реализацию метода stream(_:handle:):
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .hasBytesAvailable:
print("new message received")
case .endEncountered:
print("The end of the stream has been reached.")
case .errorOccurred:
print("error occurred")
case .hasSpaceAvailable:
print("has space available")
default:
print("some other event...")
}
}
Обрабатываем входящие сообщения
Итак, мы готовы приступить к обработке входящих сообщений. Событие, которое нас интересует — .hasBytesAvailable, которое показывает, что поступило входящее сообщение.
Напишем метод, который обрабатывает эти сообщения. Ниже только что добавленного метода напишем следующее:
private func readAvailableBytes(stream: InputStream) {
//1
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: maxReadLength)
//2
while stream.hasBytesAvailable {
//3
let numberOfBytesRead = inputStream.read(buffer, maxLength: maxReadLength)
//4
if numberOfBytesRead < 0, let error = stream.streamError {
print(error)
break
}
// Construct the Message object
}
}
- Устанавливаем буфер, в который будем читать поступающие байты.
- Крутимся в цикле, пока во входящем потоке есть, что читать.
- Вызываем read(_:maxLength:), который считывает байты из потока и помещает их в буфер.
- Если вызов вернул отрицательное значение — возвращаем ошибку и выходим из цикла.
Нам нужно вызывать этот метод как только у нас во входящем потоке есть данные, так что идем к оператору switch внутри метода stream(_:handle:), находим переключатель .hasBytesAvailable и вызываем этот метод сразу после оператора print:
readAvailableBytes(stream: aStream as! InputStream)
В этом месте у нас готовенький буфер полученных данных!
Но нам еще нужно превратить этот буфер в содержимое таблицы сообщений.
Разместим этот метод за readAvailableBytes(stream:).
private func processedMessageString(buffer: UnsafeMutablePointer<UInt8>,
length: Int) -> Message? {
//1
guard
let stringArray = String(
bytesNoCopy: buffer,
length: length,
encoding: .utf8,
freeWhenDone: true)?.components(separatedBy: ":"),
let name = stringArray.first,
let message = stringArray.last
else {
return nil
}
//2
let messageSender: MessageSender =
(name == self.username) ? .ourself : .someoneElse
//3
return Message(message: message, messageSender: messageSender, username: name)
}
Сначала мы инициализируем String, используя буфер и размер, которые передаем в этот метод.
Текст будет в UTF-8, по окончании освобождаем буфер, и делим сообщение по символу ':', чтобы разделить имя отправителя и собственно сообщение.
Теперь мы разбираем, наше это сообщение или пришло от другого участника. На продакте здесь можно создавать что-то вроде уникального токена, для демки достаточно этого.
Наконец, их всего этого хозяйства мы формируем экземпляр Message и возвращаем его.
Для использования этого метода добавьте следующий if-let в конце цикла while в методе readAvailableBytes(stream:), сразу после последнего комментария:
if let message =
processedMessageString(buffer: buffer, length: numberOfBytesRead) {
// Notify interested parties
}
Сейчас всё готово для того, чтобы передать кому-то Message… Но кому же?
Создаём протокол ChatRoomDelegate
Итак, нам нужно проинформировать ChatRoomViewController.swift о новом сообщении, но у нас нет ссылки на него. Так как он содержит сильную ссылку на ChatRoom, мы можем попасть в ловушку цикла сильных ссылок.
Это идеальное место для создания протокола-делегата. ChatRoom всё равно, кому нужно знать про новые сообщения.
Вверху ChatRoom.swift добавим новое определение протокола:
protocol ChatRoomDelegate: class {
func received(message: Message)
}
Теперь внутри класса ChatRoom добавим слабую ссылку для хранения того, кто станет делегатом:
weak var delegate: ChatRoomDelegate?
Теперь допишем метод readAvailableBytes(stream:), добавив следующую строчку внутри конструкции if-let, под последним комментарием в методе:
delegate?.received(message: message)
Возвращаемся к ChatRoomViewController.swift и добавляем следующее расширение класса, которое обеспечивает соответствие протоколу ChatRoomDelegate, сразу после MessageInputDelegate:
extension ChatRoomViewController: ChatRoomDelegate {
func received(message: Message) {
insertNewMessageCell(message)
}
}
Исходный проект уже содержит необходимое, так что insertNewMessageCell(_:) примет ваше сообщение и отобразит в тейблвью правильную ячейку.
Теперь назначим вьюконтроллер делегатом, добавив это во viewWillAppear(_:) сразу после вызова super.viewWillAppear()
chatRoom.delegate = self
Теперь откомпилируйте и запустите приложение. Введите имя и тапните return.
Вы увидите ячейку о вашем подключении к чату. Ура, вы успешно послали сообщение серверу и получили от него ответ!
Отправление сообщений
Теперь, когда ChatRoom может отправлять и получать сообщения, самое время обеспечить пользователю возможность отправлять свои собственные фразы.
В ChatRoom.swift добавьте следующий метод в конце определения класса:
func send(message: String) {
let data = "msg:\(message)".data(using: .utf8)!
_ = data.withUnsafeBytes {
guard let pointer = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
print("Error joining chat")
return
}
outputStream.write(pointer, maxLength: data.count)
}
}
Этот метод похож на joinChat(username:), который мы написали раньше, за исключением того, что у него префикс msg перед текстом (для обозначения того, что это реальное сообщение в чат).
Так как мы хотим отсылать сообщения по кнопке Send, возвращаемся к ChatRoomViewController.swift и находим там MessageInputDelegate.
Здесь мы видим пустой метод sendWasTapped(message:). Чтобы послать сообщение передайте его chatRoom:
chatRoom.send(message: message)
Собственно, это всё! Так как сервер получит сообщение и перешлёт его всем и каждому, ChatRoom будет уведомлен о новом сообщение тем же образом, как и при присоединении к чату.
Скомпилируйте и запустите приложение.
Если вам не с кем сейчас початиться, запустите новое окно Терминала и введите:
nc localhost 80
Это подключит вас к серверу. Теперь вы можете подключиться к чату, используя тот же «протокол»:
iam:gregg
А так — послать сообщение:
msg:Ay mang, wut's good?
Поздравляю, вы написали клиента для чата!
Подчищаем за собой
Если вы занимались когда-либо разработкой приложений, которые активно читают/пишут в файлы, то вы должны знать, что хорошие разработчики закрывают файлы по окончании работы с ними. Дело в том, что соединение через сокет обеспечивается дескриптором файла. Это означает, что по окончании работы нужно его закрыть, как любой другой файл.
Для этого добавьте в ChatRoom.swift следующий метод после определения send(message:):
func stopChatSession() {
inputStream.close()
outputStream.close()
}
Как вы наверняка догадываетесь, этот метод закрывает потоки, так что вы больше не сможете получать и отправлять сообщения. Кроме того, потоки удаляются из цикла выполнения (run loop), в который мы ранее их помещали.
Добавьте вызов этого метода в секцию .endEncountered в операторе switch внутри stream(_:handle:):
stopChatSession()
Затем вернитесь к ChatRoomViewController.swift и сделайте то же самое во viewWillDisappear(_:):
chatRoom.stopChatSession()
Всё! Теперь точно всё!
Заключение
Теперь, когда вы овладели основами сетевого взаимодействия при помощи сокетов, вы можете углубить свои познания.
UDP Sockets
Это приложение — пример сетевого взаимодействия при помощи TCP, которое гарантирует доставку пакетов по назначению.
Однако можно использовать и UDP сокеты. Этот тип соединения не гарантирует доставки пакетов по назначению, однако он значительно быстрее.
Особенно полезно это в играх. Когда-нибудь испытывали лаг? Это означало, что у вас было плохое соединение и множество UDP пакетов было потеряно.
WebSockets
Другая альтернатива HTTP в приложениях — технология, называемая вебсокеты.
В отличие от обычных TCP сокетов, вебсокеты для установления взаимодействия используют HTTP. С их помощью можно достичь того же, что и с обычными сокетами, но с комфортом и безопасностью, как в браузере.
Комментарии (2)
6eromKYcIIexy
24.09.2019 18:33Мне лично не приходилось самому писать нечто подобное, но Спасибо за разбор!
Сам юзал для чатов чаще всего socket.io, rabbitMq
Mixalych
И как всегда — в статьях такого плана, самое интересное в конце — есть еще вебсокеты. Вот есть у меня сейчас задача — написать мессенджер, почитал много статей, и везде сову нужно дорисовать самому. А собственно, интересны такие вопросы:
1. Сами же написали, что есть вебсокеты, socket.io. Там протокол бинарный и создан именно для этого — у автора статьи — текстовый. Ну да ладно, не о протоколах речь. О том, что в предложенных протоколах уже есть двухсторонний обмен — emit, broadcast, ping и пр.
2. Онлайн обмен даже не вызывает вопросов, хотя… Если во время отправки сообщения отвалился инет — как работать с очередями, сложить все, что нужно доотправить в sqlite и по alert'ам от календаря отправлять повторно? Сколько раз…
3. Как получать новые сообщения — от последнего id или по дате создания (с датой не совсем наверное идея, ибо зависит от локали устройства)?
4. Как быть в ситуации, когда коннектов от одного юзера несколько — яблоко и андроид, для примера? Понимаю, что что-то вроде socket.in_room(id).emit()… Но хочется подробностей.
5. В чем хранить сообщения (и очередь) на серваке — rabbit, mysql (ща полетят помидоры), mongo? Забирать все это по модному graphql или не прыгать выше головы, чтобы понимать как там все под капотом?!
6. Уведомление пушем, когда клиент не в сети — тоже момент так себе — tcp может жить и при физическом обрыве линии согласно выставленному таймауту, да даже пинги согласно протокола (socket.io, про него речь, tcp «гарантирует» доставку) не дают гарантии, что сокет клиента живой, соответственно, нам нужен ask-пакет доставки, и уже по таймауту, если ответа нет, слать пуш. Слать пуш — в канал firebase — не сложно. Интересно, как хранить все токены от одного пользователя.
И таких вопросов очень много, и больше они интересуют — техническая сторона, которую сложно найти на просторах инета. Да, есть вотсап с измененным xmpp на erlang серверах, но вот именно хочется внутрянки, у телеги вроде бы исходники открыты, но большая часть техно-«вкусняшек» скомпилено в mit_proto.
Если у хабрасообщества есть ссылки на полезную инфу в этом направлении — спасибо заранее.
Зы: Для себя вижу так — чтобы развить немного себя — берем flutter, soket.io третьей версии, firebase и sqlite, пытаемся делать все асинхронно, запрашиваем все от последнего id сообщения, на сервере — для начала берем workerman для протокола, redbean — orm для мускула с отключенным frooze (чтобы сам столбцы таблицы создавал). Закладываем сразу функционал комнат (room id), изначально только двое в диалоге. Но что-то подсказывает, что не все правильно в архитектурном смысле. Вот тут и не хватает инфы/знаний/бубна...