
Привет. В iOS разработке работа с сетью является одной из ключевых задач. Для этого существует целый арсенал инструментов: от высокоуровневого URLSession до работы с низкоуровневыми BSD сокетами.
В этой статье мы разберем список доступных инструментов, напишем и запустим сервер внутри iOS приложения, используя самый низкоуровневых инструмент (BSD sockets). И закрепим это небольшим проектом, который будет использовать наш сервер. Им будет небольшая админка, с помощью которой можно будет загружать файлы из файловой директории приложения через веб браузер (с других устройств в локальной сети).
Оглавление
Сетевые слои и их связь с инструментами для iOS разработки
Перед тем как ринуться в бой и писать приложение с сервером, давайте разберемся в теоретической части. Начнем с того, что вспомним, какие вообще уровни есть в сетевых моделях. В зависимости от модели выделяют разное количество слоев. Например, в классической модели OSI (более теоретической) имеем 7 слоев, а в TCP/IP модели (более практической) - имеем 4 слоя. Но они соотносятся друг с другом.

Все уровни разбирать тут не будем (приложу материалы в конце, где можно будет почитать про них подробнее). Нас интересуют 2 слоя: Application (который в OSI модели делится на Application, Presentation и Session) и Transport.
1) Application - это слой, с которым мы, как мобильные разработчики, работаем напрямую. Здесь реализуются прикладные протоколы, например, HTTP или WebSocket. Если говорить об iOS разработке, то URLSession как раз и предоставляет API для работы с определенными протоколами Application слоя.
2) Transport - это слой, который отвечает непосредственно за передачу данных и качество доставки. Два основных протокола в нем - это TCP (более надежный, гарантирующий целостность отправки данных) и UDP (без гарантий доставки, но более быстрый из-за этого). С точки зрения iOS точкой входа в этот слой является библиотека Network, с помощью которой можно реализовывать кастомные протоколы Application слоя.
Давайте попробуем спроецировать инструменты iOS разработки на модель TCP/IP. Для этого сначала разберемся, какие инструменты вообще существуют:
URLSession - высокоуровневая абстракция для работы c HTTP/WebSocket запросами (то есть с протоколами Application уровня).
Network - абстракция над Transport и частично над Application уровнями. Предоставляет инструменты для работы с TCP, UDP и TLS.
CFNetwork - работает над Transport и частично над Application уровнями. По функционалу пересекается с Network, но это старый фреймворк, который во многом заменен более современным Network (эволюционная замена). Реализует работу со старыми версиями HTTP, FTP (загрузка файлов), Bonjour (обнаружение устройств).
C POSIX (BSD) sockets - набор методов для работы с сетью на низком уровне. Это также набор функций для работы с Transport слоем. Методы POSIX API направляют вызовы в ОС (ядро). Именно на Transport уровне осуществляется работа с протоколами TCP и UDP.
SwiftNIO - разработан Apple, но не входит в состав SDK и подключается как сторонняя зависимость. Работает над Application и Transport уровнями, но отличается от перечисленных выше фреймворков тем, что содержит готовые инструменты для разработки серверной части на swift (помимо клиентских).
Нарисуем из этого стека технологий небольшую диаграмму, на которой можно будет увидеть соответствие между iOS инструментами и TCP/IP уровнями:

В конечном итоге все взаимодействие с сетью опирается на BSD сокеты.
У Apple есть technote TN3151: Choosing the right networking API, где описано, как выбрать подходящий фреймворк в зависимости от задач и протокола.
Важно отметить, что не смотря на разнообразие инструментов, ни один из них не поддерживает весь спектр протоколов Application уровня. Например, если вам потребуется работать с SMTP/POP3/IMAP или каким-либо кастовым протоколом, придется реализовывать его самостоятельно. Для этого необходимо будет использовать инструменты Transport уровня - это и будет вашей собственной реализацией Application уровня.
Для обычных задач обычно достаточно возможностей URLSession. Однако в некоторых случаях требуется работать с специализированными протоколами, которые URLSession не поддерживает. Например, если необходимо работать напрямую с почтовым сервером по SMTP. В этом случае можно либо реализовать протокол самостоятельно, либо воспользоваться готовым решением (например, SwiftSMTP).
Что выбрать, если требуется написать серверную часть? URLSession - это чисто клиентский API. С помощью него можно отправлять запросы, но не принимать. Вернемся к нашей схеме: мы можем выбрать SwiftNIO (с готовым Application слоем), либо взять любой framework, предоставляющий возможность работать с Transport (Network, CFNetwork, BSD sockets). Но в таком случае необходимо будет реализовывать Application протоколы для server side самостоятельно.
Помимо SwiftNIO, на рынке существуют сторонние (более высокоуровневые) решения для server side swift. Например Vapor или Kitura. Они реализуют достаточно обширную инфраструктуру для работы с северной частью, включая маршрутизацию, обработку запросов, работу с базами данных и т.д.
Конечно, проще затянуть одну из этих библиотек, если это допустимо в рамках проекта. Однако всегда остается вариант реализовать решение самостоятельно.
Плюсы кастомной реализации:
Низкоуровневый контроль
Отсутствие сторонних зависимостей
Минусы:
Реализация и отладка потребует времени
Более высокая вероятность ошибок
Низкоуровневая реализация, как уже упоминалось ранее, возможна с помощью: CFNetwork, Network или POSIX API. Последний идентичен (или, как минимум, схож) между различными языками программирования и операционными системами. CFNetwork и Network доступны только в экосистеме Apple, но они более высокоуровневые (по сравнению с POSIX).
Мы, разумеется, выберем более сложный путь - реализация через POSIX API.
Во время подготовки этого материала материала в репе Swift опубликовали Vision for Networking in Swift. В нем подсвечивают, что на каждом сетевом уровне существует ряд инструментов, которые дублируют решения для одних и тех же задач (что мы, собственно, и наблюдаем выше). В будущем планируется создание многоуровневого решения, которое покроет все сетевые уровни (в том числе сервер из коробки).
Пишем TCP сервер с помощью POSIX API
Весь исходный код можно посмотреть тут, ниже разберем самое основное, пропуская некоторые моменты "вокруг" реализации в угоду компактности.
Пойдем к написанию сервера. Для начала набросаем необходимый нам костяк:
final class TCPServer { let port: UInt16 init(port: UInt16 = 8081) { self.port = port } deinit { stop() } func start() throws { } func stop() { } } enum SocketError: LocalizedError { case socketCreationFailed(errno: Int32) case bindFailed(errno: Int32) case listenFailed(errno: Int32) case writeFailed(errno: Int32) case writeTimeout var errorDescription: String? { switch self { case .socketCreationFailed(let err): return "Socket creation failed: \(String(cString: strerror(err)))" case .bindFailed(let err): return "Bind failed: \(String(cString: strerror(err)))" case .listenFailed(let err): return "Listen failed: \(String(cString: strerror(err)))" case .writeFailed(let err): return "Write failed: \(String(cString: strerror(err)))" case .writeTimeout: return "Write timeout" } } }
Это в целом все, что должно торчать наружу. Клиент должен иметь возможность:
Запустить сервер на определенном порту
Остановить сервер
Проверить, запущен ли сервер (параметр добавим чуть позже)
Метод запуска помечаем ключевым словом throws, так как в процессе могут возникнуть ошибки. Весь пулл ошибок тоже объявим заранее.
Давайте начнем реализовывать start:
final class TCPServer { // ... private var currentSocket: Int32 = -1 func start() throws { // 1 currentSocket = socket(AF_INET, SOCK_STREAM, 0) // 2 guard currentSocket >= 0 else { throw SocketError.socketCreationFailed(errno: errno) } // ... } }
Тут мы начинаем работать с методами POSIX. Метод
socketсоздает нам сокет, учитывая параметры: domain, type и protocol.Параметр domain определяет семейство адресов для сокета. Наиболее часто используемые значения - AF_INET, AF_INET6 и AF_UNIX. Последний необходим для коммуникации между процессами. Первые два уже по сети, отличаются типами адресов (ipv4 или ipv6), для нашего примера будем использовать ipv4 (AF_INET).
Параметр type определяет тип сокета. Наиболее часто используемые SOCK_STREAM (TCP сокет) и SOCK_DGRAM (UDP сокет). Нам нужен TCP, так как далее будем реализовывать HTTP слой, который работает поверх TCP сокетов.
Параметр protocol определяет протокол. Основными опять же могут быть TCP (IPPROTO_TCP) или UDP (IPPROTO_UDP). Можно выставить этому параметру 0, тогда протокол определится автоматически на основе типа, у нас это будет IPPROTO_TCP.
Результат выполнения функции - целое число, идентифицирующее сокет (файловый дескриптор сокета). Если метод завершился с ошибкой, то он вернет -1 (такую схему обработки ошибок мы будем встречать в дальнейшем со всеми POSIX методами). Конкретную ошибку можно получить из глобальной переменной
errno, куда записывается код последней ошибки.
Мы создали сокет, далее нам надо его настроить:
func start() throws { currentSocket = socket(AF_INET, SOCK_STREAM, 0) guard currentSocket >= 0 else { throw SocketError.socketCreationFailed(errno: errno) } // 1 var reuseAddr: Int32 = 1 if setsockopt( currentSocket, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout.size(ofValue: reuseAddr)) ) < 0 { print("SO_REUSEADDR set failed: \(String(cString: strerror(errno)))") } // ... }
Для настройки сокета существует функция
setsockopt. Она принимает следующие параметры: дескриптор сокета, level, option name, option value, option length. Возвращает так же целое число. В случае ошибки тоже возвращается -1 (но тут мы не выбрасываем ошибку, так как это не критичный момент, просто логируем факт неуспеха). Касательно параметров:Дескриптор - то что мы получили на предыдущем этапе.
level - уровень настройки.
SOL_SOCKET- уровень сокета со своими настройками,IPPROTO_TCP- уровень TCP со своими. Нас интересует уровень сокета.option name - наименование опции, которую хочется настроить. Их множество, на каждом уровне свои, в данных строчках настраиваем
SO_REUSEADDRопцию, которая позволяет переиспользовать адрес. Даже при закрытии сокета он еще какое-то время остается за ним (TIME_WAIT), без этой настройки пришлось бы каждый раз при перезапусках сервера ждать это время.option value - значение для опции, в данном случае просто флаг (адрес на него).
option length - размер значения (так как это C функция и значение передается по указателю).
Необходимые опции сокета настроены, теперь нужно привязать сокет к конкретному адресу:
func start() throws { currentSocket = socket(AF_INET, SOCK_STREAM, 0) guard currentSocket >= 0 else { throw SocketError.socketCreationFailed(errno: errno) } var reuseAddr: Int32 = 1 if setsockopt( currentSocket, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout.size(ofValue: reuseAddr)) ) < 0 { print("SO_REUSEADDR set failed: \(String(cString: strerror(errno)))") } // 1 var serverAddr = sockaddr_in() serverAddr.sin_family = sa_family_t(AF_INET) serverAddr.sin_port = in_port_t(port.bigEndian) serverAddr.sin_addr.s_addr = INADDR_ANY.bigEndian // 2 let bindResult = withUnsafePointer(to: &serverAddr) { addrPtr in addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockAddrPtr in bind(currentSocket, sockAddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) } } // 3 guard bindResult >= 0 else { stop() throw SocketError.bindFailed(errno: errno) } // ... }
Создаем структуру
sockaddr_in, которая содержит информацию об адресе и порте, к которым планируется привязать сокет. В полеsin_familyуказываем семейство адресов (AF_INET - для IPv4). Полеsin_portзадает порт, причем значение обязательно должно быть преобразовано в сетевой порядок байт (network byte order) (bigEndian, разница с Little Endian). Вsin_addr.s_addrуказываемINADDR_ANY, что позволит подключаться ко всем интерфейсам (сокет будет слушать все ip системы).С помощью метода
bindпривязываем сокет с адресу и порту, созданным на предыдущем этапе. Так как это низкоуровневая C функция, она принимает структуры в виде указателей. Указатель получаем с помощью методаwithUnsafePointer. Указатель наsockaddr_inнеобходимопреобразовать к типуsockaddr, так как функцияbindожидает именно его. Преобразование выполняем с помощью методаwithMemoryRebound.Проверяем, не завершился ли метод с ошибкой, и идем дальше. На данном этапе у нас уже есть занят файловый дескриптор сокета, поэтому в случае ошибки вызываем метод
stop, который будет подчищать ресурсы (реализуем его позже).
Теперь сокет привязан к конкретному адресу. Осталось только начать его прослушивание:
final class TCPServer { // ... // 1 @Locked private(set) var isRunning = false func start() throws { currentSocket = socket(AF_INET, SOCK_STREAM, 0) guard currentSocket >= 0 else { throw SocketError.socketCreationFailed(errno: errno) } var reuseAddr: Int32 = 1 if setsockopt( currentSocket, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout.size(ofValue: reuseAddr)) ) < 0 { print("SO_REUSEADDR set failed: \(String(cString: strerror(errno)))") } var serverAddr = sockaddr_in() serverAddr.sin_family = sa_family_t(AF_INET) serverAddr.sin_port = in_port_t(port.bigEndian) serverAddr.sin_addr.s_addr = INADDR_ANY.bigEndian let bindResult = withUnsafePointer(to: &serverAddr) { addrPtr in addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockAddrPtr in bind(currentSocket, sockAddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) } } guard bindResult >= 0 else { stop() throw SocketError.bindFailed(errno: errno) } // 2 guard listen(currentSocket, 10) >= 0 else { stop() throw SocketError.listenFailed(errno: errno) } // 3 print("✅ TCP Server listening on port \(port). Socket \(currentSocket)") isRunning = true acceptConnections(socket: currentSocket) } private func acceptConnections(socket: Int32) {} // ... }
Locked
@propertyWrapper struct Locked<Value> { private var value: Value private let lock = NSLock() init(wrappedValue: Value) { self.value = wrappedValue } var wrappedValue: Value { get { lock.lock() defer { lock.unlock() } return value } set { lock.lock() defer { lock.unlock() } value = newValue } } }
В тело сервера добавляем параметр, отображающий статус сервера (запущен или нет). Locked - это самописный
propertyWrapperна локах для потокобезопасности.Включаем прослушивание соединений для сокета с помощью метода
listen. Первый параметр - дескриптор сокета. Второй параметр - максимальная длина очереди ожидающих подключений к сокету (если очередь заполнена, все последующие подключения отклоняются).Если вызов
listenзавершился успешно, сервер готов принимать клиентов. Выводим в консоль сообщение о начале прослушивания, устанавливаем флагisRunningи начинаем обрабатывать входящие подключения.
Реализуем сразу и метод stop:
final class TCPServer { // ... // 1 deinit { stop() } // 2 func stop() { guard currentSocket >= 0 else { return } isRunning = false shutdown(currentSocket, SHUT_RDWR) close(currentSocket) currentSocket = -1 print("🛑 Server stopped") } // ... }
Останавливаем сервер в
deinit, если клиент не сделал этого явно.В методе
stopсбрасываемisRunningфлаг. После этого вызываемshutdown, который завершает все текущие read/write операции. Закрываем файловый дескриптор сокета с помощью методаcloseи затираем текущее значение сокета.
Обработку входящих подключений будем реализовывать в отдельном методе acceptConnections.
final class TCPServer { // ... private let connectionsQueue: OperationQueue = { let operationQueue = OperationQueue() operationQueue.maxConcurrentOperationCount = 20 operationQueue.qualityOfService = .userInteractive return operationQueue }() private func acceptConnections(socket: Int32) { // 1 DispatchQueue.global().async { [weak self] in // 2 while self?.isRunning ?? false { // 3 var clientAddr = sockaddr_in() var addrLen = socklen_t(MemoryLayout<sockaddr_in>.size) // 4 let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { addrPtr in addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in accept(socket, sockaddrPtr, &addrLen) } } // 5 guard clientSocket >= 0 else { print("⚠️ Accept failed: \(String(cString: strerror(errno)))") continue } // 6 guard let self, self.isRunning else { close(clientSocket) return } // 7 self.connectionsQueue.addOperation { [weak self] in self?.handleClient(socket: clientSocket, address: clientAddr) } } } } private func handleClient(socket: Int32, address: sockaddr_in) {} // ... }
Обработку подключений выносим в отдельный фоновый поток.
Обрабатываем подключения в цикле, пока сервер
isRunning.Создаем пустую структуру
sockaddr_in(аналогичную той, что использовалась для привязки серверного сокета). Она заполнится автоматически адресом постучавшегося клиента (методacceptсделает это).Проводим аналогичные действия для получения указателя на нашу структуру и передаем его в метод
accept, который блокирует поток в ожидании подключения клиента. Результатом выпадения является новый файловый дескриптор клиентского сокета (или ошибка). Общение с клиентом будет происходить через этот сокет, а серверный продолжит прослушивать новые подключения.Идем на следующую итерацию цикла, если
acceptзавершился с ошибкой.acceptможет заблокировать поток на длительное время, поэтому после возврата из него необходимо повторно проверитьisRunning. Сервер мог быть остановлен за это время.Обработку каждого клиента выносим в отдельную операцию в
OperationQueue(с помощью нее можно легко ограничить максимальное количество одновременно обрабатываемых подключений). Сервер тем временем продолжит принимать новые соединения.
Работу с конкретным клиентом будем реализовывать в отдельном методе handleClient:
final class TCPServer { // ... private let handler: TCPServerHandling init(port: UInt16 = 8081, handler: TCPServerHandling) { self.port = port self.handler = handler } private func handleClient(socket: Int32, address: sockaddr_in) { // 1 var sndTime = timeval() sndTime.tv_sec = 10 sndTime.tv_usec = 0 setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &sndTime, socklen_t(MemoryLayout.size(ofValue: sndTime))) var rcvTime = timeval() rcvTime.tv_sec = 30 rcvTime.tv_usec = 0 setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &rcvTime, socklen_t(MemoryLayout.size(ofValue: rcvTime))) // 2 let clientIP = String(cString: inet_ntoa(address.sin_addr)) let clientPort = Int(UInt16(bigEndian: address.sin_port)) print("📞 New connection from \(clientIP):\(clientPort)") // 3 var accumulatedData = Data() var tempBuffer = [UInt8](repeating: 0, count: 1024 * 8) while true { let bytesRead = read(socket, &tempBuffer, tempBuffer.count) // 4 if bytesRead > 0 { accumulatedData.append(contentsOf: tempBuffer.prefix(bytesRead)) if accumulatedData.count > 1024 * 1024 { print("⚠️ Request size limit exceeded") close(socket) return } // 5 do { let response = try handler.handle(data: accumulatedData) if let data = response.data { try sendResponse(data, to: socket) } if response.shouldCloseConnection { close(socket) break } } catch { print("⚠️ Data handling failed: \(error.localizedDescription)") close(socket) break } // 6 } else if bytesRead == 0 { print("❌ Client \(clientIP):\(clientPort) disconnected") close(socket) break // 7 } else { if errno == EINTR { continue } if errno == EAGAIN || errno == EWOULDBLOCK { print("❌ Timeout for client \(clientIP):\(clientPort)") close(socket) break } print("⚠️ Read error: \(String(cString: strerror(errno)))") close(socket) break } } } private func sendResponse(_ response: Data, to socket: Int32) throws {} }
TCPServerHandling
protocol TCPServerHandling { func handle(data: Data) throws -> TCPServerResponse } struct TCPServerResponse { let data: Data? let shouldCloseConnection: Bool init(data: Data?, shouldCloseConnection: Bool = false) { self.data = data self.shouldCloseConnection = shouldCloseConnection } } extension TCPServerResponse { static let empty = TCPServerResponse(data: nil) }
Метод достаточно крупный, и вводит новые сущности, давайте разбираться:
С помощью метода
setsockoptустанавливаем таймауты на отправку (SO_SNDTIMEO) и чтение (SO_RCVTIMEO) для клиентского сокета. Если в течении указанного времени данные не отправляются или не поступают, метод завершится с ошибкой. Важно: таймауты устанавливаем именно для клиентских сокетов, а не для северного.Из структуры адреса клиента достаем IP (преобразуя его в читаемую строку помощью
inet_ntoa) и port (преобразуя его из bigEndian, так как данные там в network order). Эти данные нужны для логирования.Начинаем цикл обработки данных от клиента. Одним из ключевых отличий TCP от UDP является факт того что TCP является потоковым протоколом (stream-oriented), то есть мы заранее не знаем, сколько данных нам придет. В цикле с помощью метода
read(принимает сокет, указатель на массив байт и его размер) читаем данные сегментами (tempBuffer) и аккумулируем их вaccumulatedData.Если
bytesRead > 0, значит, получен сегмент данных. Добавляем его вaccumulatedDataи проверяем, не превышен ли лимит запроса, чтобы нас не положили по памяти (иначе клиент может засылать неограниченное кол-во данных в сокет).вводим новую сущность -
TCPServerHandling. Реализация TCP сервера будет более гибкой, если всю логику по обработке полученных данных и формированию ответа вынести в отдельный объект. Такой подход позволяет поддерживать разные протоколы Application уровня (например HTTP). Обработчик (handler) получает данные, решает, нужно ли отправлять ответ и закрывать соединение. Метод отправки данных реализуем ниже.Если
bytesRead == 0, клиент закрыл соединение. Логируем это событие.Если
bytesRead < 0, то произошла ошибка.EINTR- системное прерывание, просто идем на следующую итерацию цикла.EAGAINилиEWOULDBLOCKговорят о том что сработал таймаут (который мы выставили на первом шаге). В этом случае тоже закрываем сокет, но тут можно было и реализовать повторы с ограничением по попыткам (сделаем это в методе отправки данных). Если ошибка не равна перечисленным выше, то тоже закрываем сокет и уходим из цикла.
Финишная прямая реализации. Метод отправки ответа клиенту:
private func sendResponse(_ response: Data, to socket: Int32) throws { var attempts = 0 var totalBytesWritten = 0 // 1 while totalBytesWritten < response.count && attempts < 6 { let chunk = response[totalBytesWritten..<min(totalBytesWritten + 1024 * 8, response.count)] let bytesWritten = write(socket, Array(chunk), chunk.count) // 2 if bytesWritten < 0 { if errno == EINTR { continue } if errno == EAGAIN || errno == EWOULDBLOCK { attempts += 1 continue } throw SocketError.writeFailed(errno: errno) } else { // 3 totalBytesWritten += bytesWritten attempts = 0 } } if totalBytesWritten < response.count { throw SocketError.writeTimeout } }
Схема работы похожа на получение данных из сокета. Для отправки используем метод
write(аргументы аналогичные), отправляем данные чанками по 8 КБ.Ошибку обрабатываем тоже аналогично, но при таймауте (
EAGAINилиEWOULDBLOCK) сокет не закрывается - вместо этого пытаемся отправить чанк снова (с ограничением на количество попыток).При успешной отправке считаем количество отправленных байт. Если все данные отправлены (
totalBytesWritten == response.count), завершаем процесс.
В текущей реализации используются блокирующие сокеты (методы accept, read, write блокируют поток до завершения). Чтобы сделать их не блокирующими, можно добавить флаг
O_NONBLOCKпри создании сокета. Но и флоу работы соответсвенно поменяется (например, потребуется реализовывать механизмы опроса или асинхронные операции)
Хотя реализация сервера уже готова, мы пока не можем ее использовать, так как серверу необходим какой-то хэндлер. Позже мы реализуем HTTP хэндлер, но для проверки работоспособности можно написать более простой вариант:
import Foundation final class LogHandler: TCPServerHandling { func handle(data: Data) throws -> TCPServerResponse { guard let message = String(data: data, encoding: .utf8) else { throw Error.incorrectEncoding } print("Message from client: \(message)") return .empty } enum Error: Swift.Error { case incorrectEncoding } }
Этот хэндлер просто выводит в консоль все, что приходит на сервер. Если передать серверу этот хэндлер, запустить его и ввести в адресную строку браузера IP, то можно будет увидеть запросы от браузера.
Код интерфейса приложения разбирать не буду, можете ознакомится с ним тут. Он состоит из кнопки запуска/остановки и лейбла с текущим IP и портом, к которым нужно подключатся. Если запустить это приложение (и подменить хэндлер с HTTPFileManagerHandler на LogHandler на первой строчке StartServerViewController), то увидим:



Реализуем веб админку приложения
Мы реализовали TCP сервер, и теперь на его основе создадим небольшой проект. Идея в следующем: предоставить возможность устройств в той же сети (через браузер) просматривать общие характеристики устройства, файловую иерархию приложения и скачивать любые файлы из нее. По сути, это будет простая админ панель.
Перейдем к реализации обработки HTTP запросов и формированию ответов. Нам не потребуется полная поддержка HTTP протокола - только минимально необходимый функционал для наших задач.
Перед реализацией вспомним структуру HTTP запросов и ответов, которые нам предстоит обрабатывать. Возьмем для примера запрос из лога выше:
GET / HTTP/1.1 Host: 192.168.1.218 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15 Upgrade-Insecure-Requests: 1 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: ru Priority: u=0, i Accept-Encoding: gzip, deflate Connection: keep-alive
В первой строке запроса всегда содержится Request Line, которая включает:
Метод запроса (GET, POST и т.д.)
URL (адрес ресурса) (из лога выше
/)Версию протокола HTTP (HTTP/1.1)
Далее идут headers (параметры запроса). В конце может присутствовать (опционально) тело запроса (body) с произвольными данными, что особенно характерно для POST запросов. Между {Request Line + headers} и body обычно используется двойной CRLF (пустая строка).
Пример ответа:
HTTP/1.1 200 OK Content-Type: text/html Content-Length: 1024 <!DOCTYPE html> <html> Hi world </html>
У ответа структура похожая. В первой строке содержится Status Line, он состоит из:
Версии протокола (HTTP/1.1)
Кода состояния (200, 404, ...)
Краткого текстового пояснения к коду (OK, Not Found и т.д.)
После этого аналогично запросу идут заголовки. После заголовков опционально следует тело ответа. В приведенном примере это HTML код.
Далее перейдем к реализации хэндлера:
// 1 final class HTTPFileManagerHandler: TCPServerHandling { // 2 private let fileManager = FileManager.default private let rootURL = URL(fileURLWithPath: NSHomeDirectory()) private let mainPageHTMLGenerator: FileSystemHTMLGenerator init() { // 3 mainPageHTMLGenerator = FileSystemHTMLGenerator(rootURL: rootURL) } // 4 func handle(data: Data) throws -> TCPServerResponse { } } // 5 private extension HTTPFileManagerHandler { struct HTTPRequestParameters { let requestType: String let path: String let version: String } enum Error: Swift.Error, LocalizedError { case invalidEncoding case emptyHeaders case invalidRequestParameters case invalidURLParam case fileNotExists case readFileFailed case buildResponseFailed var errorDescription: String? { switch self { case .invalidEncoding: "invalidEncoding" case .emptyHeaders: "emptyHeaders" case .invalidRequestParameters: "invalidRequestParameters" case .invalidURLParam: "invalidURLParam" case .fileNotExists: "fileNotExists" case .readFileFailed: "readFileFailed" case .buildResponseFailed: "buildResponseFailed" } } } enum Constants { static let CRLF = "\r\n" } }
Класс реализует протокол
TCPServerHandling, который принимает нашTCPServer.Добавляем необходимые зависимости.
FileManager.defaultдля работы с файлами и директориями.rootURL- ссылка на корневую директорию (песочницу, выделенную область для работы с файлами конкретного приложения, подробнее тут).FileSystemHTMLGenerator- класс, отвечающий за генерацию главной HTML страницы на основе данных файловой системы (передаваемых нами).
В проекте лежит два HTML файла, которые мы будем возвращать на запросы браузера:
Статичный - html при not found (ошибке 404), который будет возвращаться, если не найдем запрашиваемую страницу/файл.
Динамический - главная страница, которая генерируется с помощью
FileSystemHTMLGenerator(На основе этого html).
Метод, который будет вызывать
TCPServer. Здесь будет реализована логика разбора HTTP запросов и формирования ответов.Структура HTTP параметров, ошибки и константы, которые мы будем использовать далее в реализации.
Теперь давайте начнем реализовывать метод handle:
final class HTTPFileManagerHandler: TCPServerHandling { // ... func handle(data: Data) throws -> TCPServerResponse { // 1 do { let params = try getHTTPParams(from: data) // 3 } catch Error.emptyHeaders { return .empty } catch { throw error } } // 2 private func getHTTPParams(from data: Data) throws -> HTTPRequestParameters { guard let request = String(data: data, encoding: .utf8) else { throw Error.invalidEncoding } let headers = request.split(separator: Constants.CRLF, omittingEmptySubsequences: false) guard !headers.isEmpty else { throw Error.emptyHeaders } let requestParameters = headers[0].split(separator: " ") guard requestParameters.count == 3, requestParameters[0] == "GET", request.contains("HTTP/") else { throw Error.invalidRequestParameters } print("New GET request:\n \(request)") return HTTPRequestParameters( requestType: String(requestParameters[0]), path: String(requestParameters[1]), version: String(requestParameters[2]) ) } }
Первым делом парсим Request Line:
Оборачиваем все методы парсинга в
do/catch. By default будем выкидывать ошибку выше,TCPServerбудет закрывать соединение на такие выбросы.Метод парсинга Request Line. Сплитим весь запрос по
CRLF, берем первое значение (это всегда должна быть Request Line), проверяем, что она соответствует требованиям, и возвращаемHTTPRequestParameters. Если что-то идет не так - выбрасываем ошибку.Ошибку
emptyHeadersобрабатываем отдельно, возвращая пустой респонс, чтобы сервер не закрывал соединение, так как клиент еще не прислал какие-то данные, и мы не можем сказать, что они невалидные. Например, при ошибкеinvalidRequestParametersмы будем закрывать соединение, так как уже знаем, что такой запрос обрабатывать не будем.
После успешной валидации Request Line необходимо посмотреть на URL из нее. Дальнейшая логика разветвляется на два сценария:
Если браузер запрашивает корень
/- озвращаем главную html страницу с всей необходимой информацией.Если запрашивается не корневой путь - клиент хочет скачать какой-то конкретный файл. В этому случае нужно подготовить соответствующий response под это дело.
Начнем с реализации первой ветки - обработки запроса к корневому пути /:
final class HTTPFileManagerHandler: TCPServerHandling { // ... func handle(data: Data) throws -> TCPServerResponse { do { let params = try getHTTPParams(from: data) // 1 let response: Data if params.path == "/" { response = try buildDirectoriesResponse() } else { // TODO: Обработка запроса к конкретному файлу } return TCPServerResponse(data: response, shouldCloseConnection: true) } catch Error.emptyHeaders { return .empty } catch { throw error } } // 2 private func buildDirectoriesResponse() throws -> Data { var responseStr = "HTTP/1.1 200 OK" + Constants.CRLF + "Content-Type: text/html" + Constants.CRLF + Constants.CRLF responseStr += mainPageHTMLGenerator.generateHTML() guard let responseData = responseStr.data(using: .utf8) else { assertionFailure("Failed to convert string to data") throw Error.buildResponseFailed } return responseData } // ... }
Проверяем на
pathиз Request Line. Если он равен/, формируем response для главной HTML страницы и возвращаем его. Сервер отправит его через сокет.Метод
buildDirectoriesResponseформирует HTML ответ в соответствии с протоколом.Status Line:
HTTP/1.1 200 OKЗаголовок:
Content-Type: text/htmlТело ответа: HTML код, сгенерированный через
mainPageHTMLGenerator
Теперь рассмотрим вторую ветку - обработку запросов к конкретным файлам. Если браузер запрашивает кастомный URL, скорее всего, пользователь хочет скачать файл.
final class HTTPFileManagerHandler: TCPServerHandling { // ... func handle(data: Data) throws -> TCPServerResponse { do { let params = try getHTTPParams(from: data) let response: Data if params.path == "/" { response = try buildDirectoriesResponse() } else { // 1 let fileURL = try getFileUrl(from: params) let fileData = try getFileData(from: fileURL) response = try buildFileResponse(with: fileData) } return TCPServerResponse(data: response, shouldCloseConnection: true) } catch Error.emptyHeaders { return .empty } catch { throw error } } // 2 private func getFileUrl(from request: HTTPRequestParameters) throws -> URL { let resolvedRootURL = rootURL.standardizedFileURL.resolvingSymlinksInPath() let url = URL(fileURLWithPath: request.path) guard url.standardizedFileURL.resolvingSymlinksInPath().path().hasPrefix(resolvedRootURL.path()) else { throw Error.invalidURLParam } return url } // 3 private func getFileData(from url: URL) throws -> Data { var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: url.path(), isDirectory: &isDirectory), !isDirectory.boolValue else { throw Error.fileNotExists } guard let fileData = fileManager.contents(atPath: url.path()) else { throw Error.readFileFailed } return fileData } // 4 private func buildFileResponse(with data: Data) throws -> Data { let responseStr = "HTTP/1.1 200 OK" + Constants.CRLF + "Content-Type: application/octet-stream" + Constants.CRLF + Constants.CRLF guard var responseData = responseStr.data(using: .utf8) else { assertionFailure("Failed to convert string to data") throw Error.buildResponseFailed } responseData.append(data) return responseData } // ... }
Для реализации второй ветки нам потребуется:
Подготовить корректный URL файла
Проверить его существование и получить его данные
Сформировать HTTP ответ с этими данными
Метод подготовки URL. Он проверяет корректность переданного пути, чтобы исключить запросы к файлам за пределами разрешенной директории. Резолвим символические ссылки и убеждаемся, что запрашиваемый путь является подпутем нашего исходного, корневого.
Метод проверки существования файла и получения его данных.
Метод формирования HTTP ответа с данными файла.
Важная ремарка: данный код будет работать только с относительно небольшими файлами (до 1-2 гб, в зависимости от девайса, на котором запускается сервер). Для передачи чего-то более крупного нужно прорабатывать chunked передачу.
Осталось только добавить обработку ошибок для возврата страницы 404 (Not Found):
final class HTTPFileManagerHandler: TCPServerHandling { // ... func handle(data: Data) throws -> TCPServerResponse { do { let params = try getHTTPParams(from: data) let response: Data if params.path == "/" { response = try buildDirectoriesResponse() } else { let fileURL = try getFileUrl(from: params) let fileData = try getFileData(from: fileURL) response = try buildFileResponse(with: fileData) } return TCPServerResponse(data: response, shouldCloseConnection: true) // 1 } catch Error.invalidURLParam, Error.fileNotExists, Error.readFileFailed { let responseData = try buildHttp404Response() return TCPServerResponse(data: responseData, shouldCloseConnection: true) } catch Error.emptyHeaders { return .empty } catch { throw error } } // 2 private func buildHttp404Response() throws -> Data { let responseStr = "HTTP/1.1 404 Not Found" + Constants.CRLF + "Content-Type: text/html" + Constants.CRLF + Constants.CRLF guard var responseData = responseStr.data(using: .utf8) else { assertionFailure("Failed to convert string to data") throw Error.buildResponseFailed } guard let html404URL = Bundle.main.url(forResource: "404", withExtension: "html") else { assertionFailure("404.html not found") throw Error.buildResponseFailed } do { responseData.append(try Data(contentsOf: html404URL)) return responseData } catch { assertionFailure("404.html not found") throw Error.buildResponseFailed } } // ... }
При возникновении ошибок
invalidURLParam,fileNotExistsилиreadFileFailedбудем возвращать ответ с 404 (Not Found) HTML кодом.Формируем response.
Все, наш хэндлер готов. Полный код можно посмотреть тут. Давайте посмотрим, как это выглядит. Заменяем LogHandler (если ранее запускали приложение с ним) на наш новый, запускаем приложение. На мобиле так же тыкаем "start server", в браузере так же вбиваем отображенный адрес. И видим следующее:

Мы, в лице сервера, заполнили HTML необходимыми данными и вернули его браузеру. Он его успешно отобразил. На этой странице можно отсмотреть основную статистику и файловую иерархию. Помимо этого можно скачать выбранный файл из иерархии.
Если попытаться скачать файл, к которому нет доступа (или ввести после ip какой-то рандомный путь), то отобразим страницу 404:

Итоги
На практическом приме мы потыкались в POSIX сокеты и написали обработку HTTP запроса с достаточно низкого уровня. Пример было бы классно дополнить шифрованием (то есть расширить реализацию до HTTPS) и добавить возможность выгружать файлы из браузера в приложение. Надеюсь, материал выше был полезен, и вы узнали что-то новое. Остаемся на связи.
Полезные ссылки
Если хочется фундаментально погрузится в теорию: книга "Компьютерные сети. Нисходящий подход" Джеймс Куроуз и Кит Росс
