实时通信技术研究(一) —— 基于Socket和TCP网络的实时聊天信息流的实现(一)

版本记录

版本号 时间
V1.0 2019.07.09 星期二

前言

实时通信在很多App上都有应用,包括我在上一家公司做的App也使用了实时通信技术,用于私聊和群聊,所以这里特意开一个专题一起学习一下实时通信技术。

开始

首先看一下写作环境

Swift 5, iOS 12, Xcode 10

从一开始,人类就梦想有更好的方式与他的弟兄们进行广泛的沟通。 从信鸽到无线电波,我们一直在努力更清晰有效地进行沟通。

在这个现代时代,一种技术已成为我们寻求相互理解的重要工具:简洁的网络socket

现在位于我们现代网络基础设施第4层layer 4的某个地方,sockets是任何在线通信的核心,从短信到在线游戏。

1. Why Sockets?

您可能想知道,“为什么我首先需要比URLSession更靠近底层的东西?”(如果您不想知道,那么请继续并假装你想知道。)

这是一个好问题!与URLSession通信的事情是它基于HTTP网络协议。使用HTTP,通信以请求 - 响应(request-response)方式发生。这意味着大多数应用程序中的大多数网络代码遵循相同的模式:

  • 1) 从服务器请求一些JSON。
  • 2) 在回调或代理方法中接收和使用所述JSON。

但是,当你希望服务器能够告诉你的应用程序时呢?这对HTTP来说并不是很有效。

当然,您可以通过不断ping服务器并查看它是否有更新(也就是轮询)来使其工作,或者您可以使用长轮询long-polling等技术。但是这些技术可能会有点不自然,每个都有自己的陷阱。

在一天结束时,如果它不适合这项工作,为什么要限制自己使用这种请求 - 响应模式呢?

在这个iOS流教程中,您将学习如何使用底层抽象级别并直接使用sockets来创建实时聊天室应用程序。

您的聊天室应用程序将使用在聊天会话期间保持打开状态的输入和输出流,而不是使用每个客户端都必须检查服务器是否有新消息这种方式。

首先,打开聊天应用程序和用Go编写的简单服务器。

您不必担心自己编写任何Go代码,但是您需要启动并运行此服务器才能为其编写客户端。

2. Getting Your Server to Run

初始材料中的服务器使用Go并为您预编译。 如果您不是那种信任您在网络上找到的预编译可执行文件的人,您可以使用材料中的源代码自行编译。

要运行预编译的服务器,请打开终端,导航到初始材料目录并输入以下命令:

sudo ./server

出现提示时,请输入您的密码。 输入密码后,您应该看到:Listening on 127.0.0.1:80

注意:您必须使用特权运行服务器 - 因此是sudo命令 - 因为它侦听端口80。所有小于1024的端口号都是特权端口,需要root访问权限才能绑定它们。

您的聊天服务器已准备就绪! 您现在可以跳到下一部分。

如果您想自己编译服务器,则需要使用Homebrew安装Go

如果您还没有Homebrew,那么您必须先安装它然后才能开始。 打开终端并粘贴以下行:

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

然后使用下面这个命令安装Go

brew install go

完成后,导航到入门材料的目录并使用build构建服务器。

go build server.go

最后,您可以使用本节开头列出的命令启动服务器。

3. Looking at the Existing App

接下来,打开DogeChat项目并构建并运行它以查看已经为您构建的内容。

如上所示,DogeChat当前设置为允许用户输入用户名然后进入聊天室。

不幸的是,上一个工作的人不知道如何编写聊天应用程序,所以你得到的只是用户界面和基本的管道,你必须实现网络层。


Creating a Chat Room

要开始实际编码,请导航到ChatRoomViewController.swift。在这里,您可以看到您有一个准备好的视图控制器,并且能够从输入栏接收字符串作为消息。它还可以通过table view显示消息,其中包含使用Message对象配置的自定义单元格。

由于您已经有了ChatRoomViewController,因此创建一个ChatRoom类来处理繁重的工作是有意义的。

在开始编写新类之前,请快速列出其职责。您希望此类负责以下任务:

  • 1) 打开与聊天室服务器的连接。
  • 2) 允许用户通过提供用户名加入聊天室。
  • 3) 允许用户发送和接收消息。
  • 4) 完成后关闭连接。

既然您知道自己想要什么,请按Command-N创建一个新文件。选择Swift文件并将其命名为ChatRoom

1. Creating Input and Output Streams

接下来,将ChatRoom.swift中的代码替换为:

import UIKit

class ChatRoom: NSObject {
  //1
  var inputStream: InputStream!
  var outputStream: OutputStream!

  //2
  var username = ""

  //3
  let maxReadLength = 4096
}

在这里,您已经定义了ChatRoom类并声明了您需要进行通信的属性。

  • 1) 首先,声明输入和输出流。 通过一起使用这对类,您可以在应用程序和聊天服务器之间创建基于socket的连接。 当然,您将通过输出流发送消息并通过输入流接收消息。
  • 2) 接下来,您定义username,您将在其中存储当前用户的名称。
  • 3) 最后,您声明maxReadLength。 此常量会限制您可以在任何单个消息中发送的数据量。

接下来,转到ChatRoomViewController.swift并将chat room属性添加到顶部的属性列表中。

let chatRoom = ChatRoom()

现在您已经设置了类的基本结构,现在是时候敲掉清单中的第一件事了:打开应用程序和服务器之间的连接。


Opening a Connection

返回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,但如果你遇到需要的东西有点不同的情况,还有其他选择。

接下来,指定hostname。在这种情况下,您将连接到本地计算机;如果您有远程服务器的特定IP地址,您也可以在此处使用它。

然后,指定您通过port 80连接,这是服务器侦听的端口。

最后,将指针传递给读取和写入流,以便函数可以使用它在内部创建的连接读取和写入流来初始化它们。

现在您已经初始化了流,您可以通过在setupNetworkCommunication()的末尾添加以下行来存储对它们的保留引用:

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

在非管理对象上调用takeRetainedValue()允许您同时获取保留的引用并去掉不平衡的retain,以便以后不会泄漏内存。 现在,您可以在需要时使用输入和输出流。

接下来,您需要将这些流添加到run loop中,以便您的应用程序能够正确地响应网络事件。 通过将这两行添加到setupNetworkCommunication()的末尾来实现:

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

最后,你准备打开防洪闸了! 要开始派对,请添加到setupNetworkCommunication()的底部:

inputStream.open()
outputStream.open()

这就是它的全部内容。 要完成,请转到ChatRoomViewController.swift并将以下行添加到viewWillAppear(_ :)

chatRoom.setupNetworkCommunication()

您现在在客户端应用程序和在localhost上运行的服务器之间建立了打开的连接。

如果需要,您可以构建和运行您的应用程序,但是您会看到之前看到的相同内容,因为您尚未尝试对连接执行任何操作。


Joining the Chat

现在您已经建立了与服务器的连接,现在是时候开始进行通信了! 你要说的第一件事就是你认为自己到底是谁。 之后,您将要开始向人们发送消息。

这提出了一个重点:由于您有两种消息,因此您需要找到一种方法来区分它们。

1. The Communication Protocol

使用底层TCP级别的一个优点是您可以定义自己的“协议”来决定消息是否有效。

使用HTTP,您需要考虑所有那些讨厌的动词,如GET,PUT和PATCH。 您需要构建URL并使用适当的header和各种东西。

这里你只有两种消息。 你可以发送:

iam:Luke

进入房间并告知世界你的消息,你可以说:

msg:Hey, how goes it, man?

向房间里的其他人发送消息。

这很简单但也很不安全,所以不要在工作中使用它。

现在您已了解服务器的期望,您可以在ChatRoom上编写一个方法以允许用户进入聊天室。 它需要的唯一参数是所需的用户名。

要实现它,请在您刚刚在ChatRoom.swift中编写的setup方法下面添加以下方法:

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)
  }
}
  • 1) 首先,使用简单的聊天室协议构建您的消息。
  • 2) 然后,您保存名称,以便以后可以使用它来发送聊天消息。
  • 3) withUnsafeBytes(_ :)提供了一种方便的方法来处理闭包安全范围内的某些数据的不安全指针版本。
  • 4) 最后,将消息写入输出流。 这可能看起来比你想象的要复杂一点,但是write(_:maxLength :)引用了一个不安全的指向字节的指针作为你在上一步中创建的第一个参数。

现在您的方法已准备就绪,打开ChatRoomViewController.swift并添加一个调用以在viewWillAppear(_ :)底部加入聊天。

chatRoom.joinChat(username: username)

现在,构建并运行您的应用程序。 输入您的姓名,然后点按return以查看...

还是一样没变化?

现在,坚持下去,有一个很好的解释。 转到您的终端。 在Listening on 127.0.0.1:80下,如果你的名字不是Brody,你应该看到Brody has joined,或类似的东西。

这是个好消息,但你宁愿在手机屏幕上看到一些成功的迹象。


Reacting to Incoming Messages

服务器发送传入的消息,例如您刚刚发送给房间中每个人的加入消息,包括您。 幸运的是,你的应用程序已经设置为在ChatRoomViewController的消息表中显示任何类型的传入消息作为单元格。

您需要做的就是使用inputStream来捕获这些消息,将它们转换为Message对象,然后将它们传递给该表来完成它的工作。

为了对收到的消息做出反应,您需要做的第一件事就是让您的chat room成为输入流的代理。

为此,请转到ChatRoom.swift的底部并添加以下扩展名。

extension ChatRoom: StreamDelegate {
}

既然您已经说过遵循StreamDelegate,那么您可以声称自己是inputStream的代理。

在调度调用schedule(in:forMode:)之前直接将以下行添加到setupNetworkCommunication()

inputStream.delegate = self

接下来,将此实现的stream(_:handle :)添加到扩展名:

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

1. Handling the Incoming Messages

在这里,您已经准备好对与Stream相关的传入事件做些什么。 您真正感兴趣的事件是.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
  }
}
  • 1) 首先,设置一个缓冲区,您可以在其中读取传入的字节。
  • 2) 接下来,只要输入流具有要读取的字节,就会循环。
  • 3) 在每一点上,您将调用read(_:maxLength :),它将从流中读取字节并将它们放入您传入的缓冲区中。
  • 4) 如果对read的调用返回负值,则会发生一些错误并退出。

您需要在输入流具有可用字节的情况下调用此方法,因此请在stream(_:handle :)中的switch语句中转到.hasBytesAvailable,并在print语句下面调用您正在处理的方法。

readAvailableBytes(stream: aStream as! InputStream)

在这一点上,你有一个充满字节的甜蜜缓冲区!

在完成此方法之前,您需要编写另一个帮助程序以将buffer转换为Message对象。

将以下方法定义放在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)
}
  • 1) 首先,使用传入的缓冲区buffer和长度length初始化String。将文本视为UTF-8,告诉String在完成文本时释放字节缓冲区然后将传入消息拆分为:character,这样你可以将发件人的姓名和消息视为单独的字符串。
  • 2) 接下来,您将确定您或其他人是否根据姓名发送了消息。 在生产应用程序中,您需要使用某种唯一的token,但就目前而言,这已经足够了。
  • 3) 最后,使用您收集的部分构造一条消息并将其返回。

要使用Message构造方法,请在readAvailableBytes(stream :)中,在最后一条注释的正下方将以下if-let添加到while循环的末尾:

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

到这里以后,你们都准备将Message传递给某人......但是谁呢?

2. Creating the ChatRoomDelegate Protocol

好吧,你真的想告诉ChatRoomViewController.swift关于新消息,但你没有对它的引用。 由于它拥有对ChatRoom的强引用,因此您不希望显式创建循环依赖关系并创建ChatRoomViewController

这是设置代理协议的最佳时机。 ChatRoom不关心什么样的对象想知道新消息,它只是想告诉别人。

ChatRoom.swift的顶部,添加简单的协议定义:

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

接下来,在ChatRoom类的顶部,添加一个弱的可选属性来保存对任何人决定成为ChatRoom委托的引用:

weak var delegate: ChatRoomDelegate?

现在,您可以返回ChatRoom.swift并通过为messageif-let中添加以下内容来完成readAvailableBytes(stream :),方法中的最后一条注释下方:

delegate?.received(message: message)

要完成,请返回ChatRoomViewController.swift并添加以下扩展名,该扩展名符合此协议,位于MessageInputDelegate下方:

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

入门项目包括其余的管道,因此insertNewMessageCell(_ :)将接收您的消息并将适当的单元格添加到表中。

现在,通过在viewWillAppear(_ :)中调用super之后立即添加以下行,将视图控制器指定为chatRoom的代理:

chatRoom.delegate = self

再次构建并运行您的应用并在text field中输入您的名字,然后点按return

🎉聊天室现在成功显示一个单元格,说明您已进入房间。 您已经正式向基于socketTCP服务器发送消息并从其接收消息。


Sending Messages

现在您已经设置了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添加到你发送的文本以表示为实际消息。

由于您想在inputBar告诉ChatRoomViewController用户已点击Send时发送消息,请返回ChatRoomViewController.swift并找到MessageInputDelegate

在这里,您将看到一个名为sendWasTapped(message :)的空方法,该方法在此时被调用。 要发送消息,请将其传递给chatRoom

chatRoom.send(message: message)

这就完成了全部! 由于服务器将收到此消息,然后将其转发给所有人,因此ChatRoom会以与您加入房间时相同的方式收到新消息的通知。

构建并运行您的应用程序,然后继续为自己尝试消息传递。

如果您想看到有人聊天,请转到新的终端窗口并输入:

nc localhost 80

这将允许您在命令行上连接到TCP服务器。 现在,您可以发出应用程序用于在那里聊天的相同命令。

iam:gregg

然后,发送一个消息:

msg:Ay mang, wut's good?

恭喜,你已经成功写了一个聊天客户端!


Cleaning up After Yourself

如果您曾对文件进行过任何编程,那么您应该知道好的习惯是在完成文件后会close files。 事实证明Unix通过文件句柄代表一个开放的套接字连接,就像其他一切一样。 这意味着你需要在完成后关闭它,就像任何其他文件一样。

为此,在ChatRoom.swift中定义send(message :)后添加以下方法:

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

正如您可能已经猜到的那样,这会关闭流并使其无法发送或接收信息。 这些调用还会从您之前调度的运行循环中删除流。

要完成这项任务,请将此方法调用添加到stream(_:handle :)内的switch语句中的.endEncountered案例中:

stopChatSession()

然后,返回ChatRoomViewController.swift并将相同的行添加到viewWillDisappear(_ :)

chatRoom.stopChatSession()

这样就全部完成了。

现在你已经掌握了(或者至少看过一个简单的例子)与套接字联网的基础知识,有几个地方可以扩展你的视野。

UDP Sockets

这个iOS流教程是使用TCP进行通信的一个示例,它打开了一个连接,并保证数据包将尽可能到达目的地。

或者,您也可以使用UDP或数据报套接字进行通信。这些套接字无法保证数据包将到达,这意味着它们的速度更快,开销也更少。

它们对游戏等应用非常有用。曾经历过滞后?这意味着你的连接不良,你应该收到的很多UDP数据包都会被丢弃。

WebSockets

对这样的应用程序使用HTTP的另一种替代方法是称为WebSockets的技术。

与传统的TCP套接字不同,WebSocket至少与HTTP保持关系,并且可以实现与传统套接字相同的实时通信目标,所有这些都来自浏览器的舒适性和安全性。

当然,您也可以将WebSockets与iOS应用程序一起使用。

Beej's Guide to Network Programming

最后,如果您真的想深入了解网络,请查看免费在线书籍Beej的网络编程指南Beej's Guide to Network Programming

本书提供了有关套接字编程的详尽解释。如果你害怕C,那么这本书可能有点令人生畏,但话又说回来,也许今天是你面对恐惧的那一天。

后记

本篇主要讲述了基于Socket和TCP网络的实时聊天信息流的实现,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,423评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,339评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,241评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,503评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,824评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,262评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,615评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,337评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,989评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,300评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,829评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,193评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,753评论 3 230
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,970评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,708评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,295评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,207评论 2 258

推荐阅读更多精彩内容