Туториал: Общение в реальном времени через сетевые сокеты

Если вы нашли опечатку в тексте, выделите ее и нажмите CTRL + ENTER.

Xcode: 
9 beta 4
Swift: 
4.0

С давних времён человек мечтал о лучших способах общения со своими собратьями на дальние расстояния, от почтовых голубей до радиоволн, мы всегда стараемся сделать наше общение более простым и эффективным.

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

Существующий где-то на четвертом слое современной сетевой инфраструктуры, сокет - основа любого онлайн общения от текстовых сообщений до онлайн игр.

Почему сокеты?

Вероятно, вам интересно, зачем нам нужно идти на более низкие уровни чем URLSession? Если вам это не интересно, тогда читайте дальше и притворитесь, что интересно…

Хороший вопрос! Суть связи через URLSession в том, что она основана на сетевом протоколе HTTP. С помощью HTTP, связь происходит по принципу запрос-ответ. Это значит, что интернет-код в большинстве программ использует следующий шаблон:

1. Запрос JSON у сервера.

2. Получение и использование запрошенного JSON в callback’е или функции делегата.

Но что если вы хотите, чтобы сервер вам сообщал о каком-нибудь событии? Данный случай не совсем подходит для HTTP. Конечно, вы можете сделать это непрерывно отправляя запросы серверу и проверяя состояние (принцип polling), или можете применить немного хитрости и использовать long-polling, но эти способы неправильны и имеют свои подводные камни. В конце концов, зачем ограничивать себя парадигмой "запрос-ответ", тем более, что она не подходит для решения задачи?

В этом руководстве вы узнаете как опустить уровень абстракции и использовать сокеты напрямую, для создания чат приложения работающего в реальном времени.

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

Поехали

Скачайте стартовый проект, который включает в себя приложение чата и простой сервер написанный на Go. Не переживайте, вам не придется писать какой-либо код на Go, но вам нужно будет запустить этот сервер, чтобы написать клиент для него.

Запуск сервера

Сервер написан на языке Go и уже скомпилирован. Если вы не доверяете заранее скомпилированному коду из интернета, мы включили исходный код в проект, который вы можете скомпилировать самостоятельно.

Чтобы запустить сервер откройте терминал, перейдите в директорию проекта и введите следующую команду (понадобится ввести пароль):

sudo ./server

После ввода пароля вы должны увидеть: Listening on 127.0.0.1:80. Ваш сервер готов! 

Можете пропустить следующую часть.

Если хотите скомпилировать сервер самостоятельно, нужно установить Go через Homebrew.

Если у вас не установлен Homebrew, установите его. Для этого откройте терминал, и выполните следующую команду:

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

Затем используйте команду чтобы установить Go:

brew install go

После завершения переместитесь в папку с проектом и соберите сервер при помощи команды build:

go build server.go

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

Рассмотрим стартовый проект

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

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

Создаем чат

Чтобы начать писать программу, откройте ChatRoomViewController.swift. Здесь вы увидите ViewController, который готов получать строки как сообщения из поля ввода, и отображать их в виде таблицы TableView с ячейками которые созданы как объекты класса Message.

Поскольку у вас уже есть ChatRoomViewController, имеет смысл создать класс ChatRoom.

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

1. Открывал соединение с сервером чата.

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

3. Разрешал пользователю отправлять и получать сообщения.

4. Закрывал соединение по окончанию сессии.

Теперь вы знаете, чего хотите, нажмите cmd + n для создания нового файла. Выберите Cocoa Touch Class и назовите его ChatRoom (subclass NSObject).

Создание входных и выходных потоков данных

Далее вставьте в файл следующий код:

import UIKit

class ChatRoom: NSObject {
  //1
  var inputStream: InputStream!
  var outputStream: OutputStream!
  
  //2
  var username = ""
  
  //3
  let maxReadLength = 4096
  
}

Здесь вы определяете класс ChatRoom и объявляете его свойства, которые нужны для соединения.

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

2. Следующее, у вас есть переменная для хранения имени текущего пользователя.

3. И наконец, у вас есть константа 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)
}

Вот что в нем происходит:

1. Здесь определяются два неинициализированных потока данных сокета для чтения и записи, которые не имеют автоматического управления памятью.

2. Затем вы объединяете ваши потоки чтения и записи сокета и подключаете их к сокету сервера, в нашем случае это порт 80. 

Функция принимает четыре параметра. Первый типа allocator для инициализации потоков. Вам необходимо стараться использовать kCFAllocatorDefault всегда, когда это возможно, хотя есть и другие варианты, в зависимости от того чего вы хотите добиться.

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

Наконец, вы передаете указатели на потоки данных чтения и записи, чтобы функция могла инициализировать их с подключенными потоками чтения и записи, которые будут созданы внутри нее.

Теперь, когда потоки инициализированы, вы можете сохранить ссылки на них добавив код:

inputStream = readStream!.takeRetainedValue()
outputStream = writeStream!.takeRetainedValue()

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

Затем, чтобы приложение правильно реагировало на сетевые события, эти потоки необходимо добавить в цикл выполнения. Добавьте эти две строки в конец setupNetworkCommunication.

inputStream.schedule(in: .current, forMode: .commonModes)
outputStream.schedule(in: .current, forMode: .commonModes)

Еще чуть-чуть и врата флуда будут открыты! Для того, чтобы вечеринка началась добавьте следующий код (и снова в конец setupNetworkCommunication ):

inputStream.open()
outputStream.open()

И это все что нужно. Чтобы закончить откройте ChatRoomViewController.swift и добавьте строку кода в метод viewWillAppear(_:)

chatRoom.setupNetworkCommunication()

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

Вход в чат

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

Это приводит нас к важной мысли: так как есть два типа сообщений, нужно придумать способ различать их.

Протокол соединения

Одно из преимуществ перехода на нижние уровни абстракции от TCP, это то, что вы можете определить ваш собственный “протокол” для определения допустимости сообщения. Применяя HTTP, вам нужно думать о всех этих надоедливых командах типа GET, PUT, и PATCH. Вам нужно собирать URL’ы, использовать правильные заголовки и делать много других вещей.

Здесь у нас есть лишь два типа сообщений. Вы можете отправить:

iam:Luke

Чтобы войти в чат и назвать свое имя.

А также можете сказать:

msg:Hey, how goes it mang?

Чтобы отправить сообщение каждому в чате.

Это логично и просто.

Но также и очень небезопасно, так что не применяйте это в реальных проектах.

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

Добавьте следующий метод в конец метода настроек:

func joinChat(username: String) {
  //1
  let data = "iam:\(username)".data(using: .ascii)!
  //2
  self.username = username
  
  //3
  _ = data.withUnsafeBytes { outputStream.write($0, maxLength: data.count) }
}

1. Первое, вы конструируете сообщение, используя протокол чата.

2. Сохраняете передаваемое имя, чтобы позднее использовать его в отправляемых сообщениях.

3. Наконец, вы записываете сообщение в выходящий поток. Это может выглядеть несколько сложнее, чем вы предполагали, но метод write(_:maxLength:) принимает первым параметром ссылку к unsafe указателю к байтам. Метод withUnsafeBytes(of:_:) предоставляет вам удобный способ работы с unsafe указателем на данные внутри безопасных границ в замыкании.

Теперь, когда ваш метод готов, перейдите в ChatRoomViewController.swift и добавьте вызов в конец viewWillAppear(_:).

chatRoom.joinChat(username: username)

Скомпилируйте и запустите проект, введите имя, нажмите ввод чтобы посмотреть что получилось…

Тоже самое?

Подождите, я могу обьяснить, откройте терминал. Прямо под Listening on 127.0.0.1:80 вы увидите Luke has joined или что-то подобное, в зависимости от того, какое имя вы ввели.

Это отличные новости, но вы наверняка хотите увидеть его на экране телефона.

Реагирование на входящие сообщения

Сервер принимает входящие сообщения и затем рассылает их всем в чате, включая вас. Приложение уже настроено показывать любые входящие сообщения как ячейку TableView в ChatRoomViewController‘e

Все, что нужно, так это использовать inputStream, чтобы захватить сообщение, конвертировать его в Message объект и передать его в table View.

Далее необходимо сделать чат делегатом входящего потока. Откройте ChatRoom.swift и добавьте расширение в конец файла:

extension ChatRoom: StreamDelegate {

}

Так как чат теперь подписан на протокол StreamDelegate можно объявить делегат.

Добавьте следующую команду в setupNetworkCommunication() перед вызовом schedule(_:forMode:)

inputStream.delegate = self

Следующее, добавьте stream(_:handle:) в расширение:

func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
    switch eventCode {
    case Stream.Event.hasBytesAvailable:
      print("new message received")
    case Stream.Event.endEncountered:
      print("new message received")
    case Stream.Event.errorOccurred:
      print("error occurred")
    case Stream.Event.hasSpaceAvailable:
      print("has space available")
    default:
      print("some other event...")
      break
    }
}

Теперь необходимо сделать что-нибудь со входящими событиями, которые происходят в потоке данных. Единственное, что вас интересует - это Stream.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 {
      if let _ = stream.streamError {
        break
      }
    }

    //Construct the Message object
    
  }
}

1. Настройка буфера, из которого происходит чтение входящих байтов.

2. Чтение до тех пор, пока не будут прочитаны все байты.

3. Каждый раз при вызове read(_:maxLength:) прочитываются данные из потока и передаются в буфер.

4. Если вызов команды чтения возвращает отрицательное значение, выдается ошибка.

Этот метод нужно вызывать в случае, когда входной поток имеет доступные байты, поэтому перейдите в Stream.Event.hasBytesAvailable условного оператора switch внутри stream(_:handle:) и вызовите метод над которым вы работали под командой print.

readAvailableBytes(stream: aStream as! InputStream)

На данный момент у вас есть шикарный буфер наполненный байтами! Прежде чем завершить метод, вам нужно будет написать еще один помощник, чтобы превратить буфер в объект Message.

Поместите нижеприведенное определение метода под readAvailableBytes(_:):

private func processedMessageString(buffer: UnsafeMutablePointer<UInt8>,
                                    length: Int) -> Message? {
  //1
  guard let stringArray = String(bytesNoCopy: buffer,
                                 length: length,
                                 encoding: .ascii,
                                 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)
}

1. Во-первых, вы инициализируете строку используя буфер и длину. Вы обращаетесь с текстом как ASCII, задаете строке освободить буфер байтов когда закончили с ним, а затем разделяете входящее сообщение на символе : чтобы вы могли получить имя отправителя и фактическое сообщение в виде отдельных строк.

2. Затем вы выясняете, отправили ли вы или кто-то другой сообщение на основе имени. В реальном приложении вы бы захотели использовать уникальные признаки, но пока можно и без них.

3. Наконец, вы создаете сообщение из собранных вами частей и возвращаете его.

Чтобы использовать ваш метод построения сообщения, добавьте следующее if-let в конец readAvailableBytes(_:).

if let message = processedMessageString(buffer: buffer, length: numberOfBytesRead) {
  //Notify interested parties
  
}

С этого момента все настроено для передачи сообщения кому-нибудь, но кому?

Создание протокола ChatRoomDelegate

Так, вы действительно хотите сообщить ChatRoomViewController.swift о новом сообщении, но у вас нет ссылки для этого. Поскольку он содержит сильную ссылку на ChatRoom, вы не хотите создавать циклическую зависимость и создавать ChatRoomViewController свойство.

Это идеальное время для создания протокола делегата. ChatRoom не заботится о том, какой объект хочет знать о новых сообщениях, он просто хочет рассказать кому-то.

Перейдите в начало ChatRoom.swift и добавьте простое определение протокола:

protocol ChatRoomDelegate: class {
  func receivedMessage(message: Message)
}

Затем добавьте слабое опциональное свойство чтобы хранить ссылку на того, кто станет делегатом ChatRoom.

weak var delegate: ChatRoomDelegate?

Теперь вы можете вернуться и закончить readAvailableBytes(_:) добавив следующее внутри if-let.

delegate?.receivedMessage(message: message)

Чтобы закончить работу, вернитесь в ChatRoomViewController.swift и добавьте следующее расширение подписывающее его к этому протоколу, прямо под расширением MessageInputDelegate.

extension ChatRoomViewController: ChatRoomDelegate {
  func receivedMessage(message: Message) {
    insertNewMessageCell(message)
  }
}

Как я уже говорил, остальная часть программы уже настроена для вас, поэтому метод insertNewMessageCell(_:) возьмет ваше сообщение и позаботится о добавлении соответствующей ячейки в таблицу.

Теперь перейдите и подпишите ViewController быть делегатом chatRoom, добавив следующую строку сразу после вызова super в viewWillAppear(_:).

chatRoom.delegate = self

Еще раз создайте и запустите, а затем введите свое имя в текстовое поле и нажмите enter.

Окно чата теперь успешно отображает ячейку, в которой говорится, что вы вошли в чат. Вы официально отправили сообщение и получили сообщение от TCP-сервера на основе сокетов.

Отправка сообщений

Теперь, когда у вас есть класс ChatRoom, настроенный для отправки и получения сообщений, пришло время разрешить пользователям отправлять текст туда и обратно.

Вернитесь в ChatRoom.swift и добавьте следующий метод в нижнюю часть определения класса.

func sendMessage(message: String) {
  let data = "msg:\(message)".data(using: .ascii)!
  
  _ = data.withUnsafeBytes { outputStream.write($0, maxLength: data.count) }
}

Этот метод аналогичен методу joinChat (_ :), который вы написали ранее, за исключением того, что он добавляет msg к тексту, который вы отправляете, чтобы обозначить его как сообщение.

Так как вы хотите отправлять сообщения когда inputBar сообщает ChatRoomViewController о том, что пользователь нажал кнопку отправить, вернитесь в ChatRoomViewController.swift и найдите расширение MessageInputDelegate.

Здесь вы увидите пустой метод sendWasTapped (_ :), который вызывается в этот момент. Чтобы отправить сообщение, просто передайте его в chatRoom.

chatRoom.sendMessage(message: message)

И это на самом деле все, что нужно! Таким образом сервер получит сообщение, а затем перенаправит его всем, ChatRoom будет уведомлен о новом сообщении так же, как и при входе в чат.

Запустите проект и попробуйте.

Если вы хотите, чтобы кто-то поболтал в ответ, перейдите в новое окно терминала и введите:

telnet localhost 80

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

iam:gregg

Затем отправьте сообщение.

msg:Ay mang, wut's good?

Поздравляю, вы успешно написали приложение клиент чата!

Уборка после себя

Если вы когда-либо делали какие-либо программы с файлами, вы должны знать, что хорошие граждане закрывают файлы, когда они с ними поработали. Как и все остальное в unix, открытое соединение сокета представлено через дескриптор файла, а это значит, что вам нужно закрыть его, когда вы закончили, как и любой другой файл.

Чтобы сделать это добавьте следующий метод после объявления sendMessage(_:)

func stopChatSession() {
  inputStream.close()
  outputStream.close()
}

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

Чтобы закончить работу, добавьте этот вызов метода в Stream.Event.endEncountered инструкции switch.

stopChatSession()

Затем вернитесь в ChatRoomViewController.swift и добавьте этот же вызов во viewWillDisappear(_:).

chatRoom.stopChatSession()

И это все, вы закончили!

А что дальше?

Скачать законченный проект можно тут.

Дальше, вы можете продолжить изучать наши туториалы по мере их появления, а также, параллельно читать перевод официальной книги по языку программирования Swift. И, для более подробного изучения языка, вы можете пройти наши курсы!

Урок подготовил: Рыбкин Денис

Источник урока.