春节假期打算了解一下这一两年逐渐变得火热的 Server-Side Swift。为了了解更底层一些的机制,以及未来在 iOS 应用中尝试这些接口,我很自然地盯上了苹果两年前发布的 SwiftNIO 这个通信库。不看不知道,SwiftNIO 的入门资料可真是少呀:官方就给了一个 API 索引;中文的资料基本还停留在 2 年多前 SwiftNIO 发布时候的简介;外网的教程则是零零碎碎,东一榔头西一棒子的。这让习惯先跟着官网上的 Getting Started 做一个简单的例子找找感觉的我有些不知所措。这个时候,我突然想起来了文档中单独标出的一句话:
It's like Netty, but written for Swift.
给不了解的朋友解释一下, Netty 是 Java 生态中非常重要的网络通信框架,从 2004 年至今已经被无数框架和工具采用。简单对照一下,会发现的确如文档所说,SwiftNIO 的使用方式与 Netty 直接对应,所以让我们来用 Netty 的 Getting Started 文档来入门 SwiftNIO 吧。
本文用 SwiftNIO 实现了 Netty 文档中提到的 DISCORD,ECHO 和 TIME 三个简单协议,最终的代码放在了 SwiftNIO-NettyExamples 这个项目中,如果它对你有帮助的话,欢迎点个 star 呀~ 另外,也希望大家能去对照一下 Netty 的文档 和 SwiftNIO 的 API 索引,看看 SwiftNIO 保留了什么,又精简了什么。
把 SwiftNIO 添加进项目
在实际写代码前,我们需要把 SwiftNIO 加入我们的项目。我这里都是创建的 Swift Package 项目并通过 SwiftPM 添加依赖的。在 Xcode 的创建项目页面中,选择 Multiplatform 中的 Swift Package:
然后在 Package.swift 中添加依赖项。下面是一个引入 SwiftNIO 的例子:
import PackageDescription
let package = Package(
name: "Example",
products: [
.executable(name: "Example", targets: ["Example"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0")
],
targets: [
.target(
name: "Example",
dependencies: [.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio")]),
]
)
修改完 Package.swift 后,等左侧的 package 自动加载完,就可以在代码中 import NIO 了。
DISCARD
成功添加依赖环境之后,让我们开始用 SwiftNIO 试着写几个小例子。我们的第一个项目是实现一个符合 DISCARD 协议的 server,也就是 server 会自动忽略 client 发来的任何消息。
第一个 ChannelHandler
SwiftNIO 通信的基本单元是 Channel。你可以把 Channel 想象成一个包装过的 socket,它可以进行异步的 read、write、connect、bind 等操作。当收到通信数据的时候,Channel 会用自己内部事先注册的流水线对数据进行处理,这个流水线就是 ChannelPipeline,流水线中的每个环节就是一个 ChannelHandler。所以如果要进行自定义的数据处理,就需要实现我们自己的 ChannelHandler。SwiftNIO 为我们提供了更方便的 ChannelInboundHandler 协议,专门用于处理传入的数据,我们一般都会基于它来写我们的 handler。 DISCARD 协议需要的 ChannelHandler 如下:
class DiscardServerHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer // 1.
func channelRead(context: ChannelHandlerContext, data: NIOAny) { // 2.
var msg: ByteBuffer = unwrapInboundIn(data)
print(msg.readString(length: msg.readableBytes)!)
}
func errorCaught(context: ChannelHandlerContext, error: Error) { // 3.
print("\(error.localizedDescription)")
context.close()
}
}
可以看到我们实现了InboundIn、channelRead、errorCaught 这 3 样,一个一个来说:
-
InboundIn用来标识这个ChannelInboundHandler的输入是什么类型的,这里我们把输入类型设为了ByteBuffer。ByteBuffer对应的是 Netty 中的ByteBuf,代表的就是一段可以随意读写的连续缓存,类似于Data。不过要注意,和 Netty 的ByteBuf不同,SwiftNIO 中的ByteBuffer不需要用release方法来释放引用。 -
channelRead函数是这个 handler 的关键,它是ChannelInboundHandler获取到数据的时候会调用的方法。虽然DISCARD协议要求我们什么都不做,但是为了能有点输出效果,我在这里打印了传入的数据。unwrapInboundIn函数会根据 1 中定下的InboundIn来转化传入的数据,这里就是转化为ByteBuffer了。数据在被转化前是NIOAny类型的,这个类型类似于Any,不同的地方在于,把NIOAny转化为ByteBuffer等特定的 SwiftNIO 类型几乎没有 overhead。 -
从名字可以看出来,
errorCaught是用来进行异常处理的,这里我们会在出现异常的时候停掉这个 handler,这种控制都是通过ChannelHandlerContext来完成的。
有了自定义的 DiscardServerHandler,我们要如何把它加入 server 呢?
创建 server
和上面一样,我们先把代码放过来,再来讲讲每部分都是干什么用的。
class DiscardServer {
private let port: Int
init(_ port: Int) {
self.port = port
}
func run() throws {
// 2. (1 在下面)
let bossGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount);
let workerGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
defer {
try! bossGroup.syncShutdownGracefully()
try! workerGroup.syncShutdownGracefully()
}
let b = ServerBootstrap(group: bossGroup, childGroup: workerGroup) // 1.
.childChannelInitializer { ch in // 3.
ch.pipeline.addHandler(DiscardServerHandler())
}
.serverChannelOption(ChannelOptions.backlog, value: 128) // 4.
.childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), // 5.
SO_KEEPALIVE), value: 1)
let channel = try b.bind(host: "localhost", port: port).wait() // 6.
try channel.closeFuture.wait() // 7.
}
static func main(_ port: Int = 8080) throws {
try DiscardServer(port).run()
}
}
这就是 server 的核心代码了,在 main.swift 中,我们只需要运行 try DiscardServer.main() 就好。那么对照着代码中的序号,我们来说说这些部分都是干啥的:
-
创建 server 实际上就是设置好 server 用的
Channel。SwiftNIO 提供了ServerBootstrap这个类来辅助我们进行设置。前文说过,Channel其实就是包装过的 socket,而对于 server 来说,实际上有 2 种 socket,一种用于监听端口(bind+listen),一种创建连接(accept)。用 SwiftNIO 的语言来说,前者是ServerSocketChannel,后者是ServerChannel。在ServerBootstrap的配置过程中,也要区分这两种,listen用的ServerSocketChannel一般用serverXxx样子的接口配置,而accept用的ServerChannel则用childXxx样子的接口配置。 -
在初始化
ServerBootstrap的时候需要传入构造好的MultiThreadedEventLoopGroup,这类型就是每个线程一个的事件循环,用于进行异步操作。注意,在退出的时候要用syncShutdownGracefully关闭事件循环,一般配合defer使用就好。我们传入了 2 个MultiThreadedEventLoopGroup,前者是给ServerSocketChannel配置的,后者则是给ServerChannel配置的。 -
初始化
ServerBootstrap后,需要用childChannelInitializer来把我们创建的DiscardServerHandler绑定在流水线上,也就是:.childChannelInitializer { ch in // 3. ch.pipeline.addHandler(DiscardServerHandler()) }这三行代码的用处了。
对于熟悉 linux socket 的朋友,下面的三行应该很好理解
.serverChannelOption(ChannelOptions.backlog, value: 128) // 4.
.childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), // 5.
SO_KEEPALIVE), value: 1)
-
前者是设置
listen函数中的backlog参数,也就是等待accept的队列的最大长度。不了解的朋友可以看一下 manual:listen(2),对应的 C 函数为:int listen(int sockfd, int backlog); -
后者则是用
setsocketopt设置 TCP 的连接包活,对应的 C 的实现为:int keepalive = 1; setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); -
配置好
Channel,就可以调用bind来绑定到对应的端口了。 -
这里是用来等待 socket 关闭的,不过对于 server 来说,在退出的时候才会关闭监听的 socket。
至此,我们就完成 DISCORD 协议的 server 了,可以用 netcat 来测试一下,用 Xcode 启动 Discard 项目,并在命令行中运行
$ nc localhost 8080
来连接端口,之后尝试输入些东西,点击回车的时候就会看到 server 部分也输出了相同的 log~
ECHO
ECHO 协议和 DISCARD 一样简单,是原封不动地把输入作为输出发回去。所以我们只需要更改自定义的 ChannelHandler 的部分就好了,server 的部分可以保持不变。
在 ChannelHandler 中进行回复
更严格地说,其实只需要改变 channelRead 就好。
class EchoServerHandler: ChannelInboundHandler {
...
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
context.write(data)
context.flush()
}
...
}
采用 ChannelHandlerContext 的 write 和 flush 方法就可以把输出发出去了,是不是很简单呢?如果觉得分成两个很麻烦的话,还有 writeAndFlush 这样的一站式接口哦。
我们仍然可以用 netcat 进行测试,这次对于任何输入,我们都会得到和输入相同的输出。
TIME
TIME 协议和上面的两个不太一样,它是在 client 连接到 server 的时候,由 server 把连接的时刻发给 client,并关闭连接。所以我们不能再通过覆写 channelRead 这个方法实现功能了,因为它在 Channel 收到数据的时候才会被调用。TIME 需要的方法是 channelActive。
用 channelActive 实现连接时刻的调用
channelAcitve 是在 Channel 准备好接受和发送数据的时候会被调用的方法。利用它我们就能实现 TIME 协议的 server 了。
class TimeServerHandler: ChannelInboundHandler {
...
func channelActive(context: ChannelHandlerContext) {
let time = ByteBuffer(integer: Int(Date().timeIntervalSince1970),
endianness: .big, as: Int.self)
let f: EventLoopFuture = context.writeAndFlush(NIOAny(time))
f.flatMap {
context.close()
}
}
...
}
这里我们把当前时刻编码为了 ByteBuffer,并使用 writeAndFlush 发送给 client。关键的一点是在发送完毕之后,根据协议我们需要关闭连接。但是因为 SwiftNIO 的操作都是异步的,如果直接写成
context.writeAndFlush(NIOAny(time))
context.close()
可能会出现消息还没有发送出去,连接就被关闭了的情况。所以我们需要利用 writeAndFlush 操作返回的 EventLoopFuture 来保证关闭连接操作是在完成发送之后进行的。
EventLoopFuture 类型类似于 JavaScript 中的 Promise,也就是一个异步操作的结果,里面的值会在异步操作完成后被解析。配合 flatMap 绑定回调函数,就能控制异步操作的顺序。对于 TIME 协议的 server,我们就是用 flatMap 保证关闭操作是在发送操作之后的。这里因为 writeAndFlush 操作的返回值为 EventLoopPromise<Void> 类型的,所以回调函数没有输入参数。
因为这次 server 发送的消息变成编码后的整数了,没办法用 netcat 验证正确性,所以我们还需要为 TIME 协议做一个 client。
创建 client
回忆一下,在上文创建 server 的时候,主要通过 ServerBootstrap 来设置 listen 和 accept 用的两种 Channel。对于 client 也是类似的,SwiftNIO 提供了 ClientBootstrap 这个辅助工具,具体的代码如下:
class TimeClient {
static func main(host: String = "localhost", port: Int = 8080) throws {
// 1.
let workGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount);
defer {
try! workGroup.syncShutdownGracefully()
}
let b = ClientBootstrap(group: workGroup) // 2.
.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), // 3.
SO_KEEPALIVE), value: 1)
.channelInitializer { ch in // 4.
ch.pipeline.addHandler(TimeClientHandler())
}
let channel = try b.connect(host: host, port: port).wait() // 5.
try channel.closeFuture.wait() // 6.
}
}
对照 server 部分的代码,应该可以很容易理解 client 的这部分:
-
先创建一个
MultiThreadedEventLoopGroup作为事件循环。 -
用 1 中创建的事件循环初始化
ClientBootstrap。 -
因为 client 只有连接用的 socket,所以在 client 这里就不再区分 server 和 child 了,这里的
channelOption相当于 server 部分的childChannelOption。 -
添加 handler,这里的
channelInitializer对应的就是 server 那边的childChannelInitializer。 -
创建完
Channel之后,尝试去连接地址。 -
等待连接结束,顺利退出。
除去一般性的创建流程之外,client 也需要自己的 ChannelHandler:
class TimeClientHandler: ChannelInboundHandler {
...
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
var m: ByteBuffer = unwrapInboundIn(data)
if let currentSecond = m.readInteger(endianness: .big, as: Int.self) {
let date = Date(timeIntervalSince1970: Double(currentSecond))
print(date)
} else {
print("failed to convert the response to Int.")
}
context.close()
}
...
}
这里的代码还是很好理解~ 从 context 中读出整数,恢复为当前时间并打印。到这里,只要分别前后运行 server 和 client 的程序,应该就可以看到 client 会打印当前时间了。
用 ByteToMessageDecoder 处理流式数据
在大功告成之前,我们还要处理一下流式通信(stream-based transport)的一个小问题,那就是数据不是按发送端的数据划分方式抵达接收端的,例如发送端可能发送了这样的三个条数据:
但接收端收到的是这样的数据流:
为了避免接收端在还没有完全接收够数据就开始进行处理,或者一气儿处理了过多的 buffer 中的数据,我们需要在接收方的 ChannelPipeline 中添加一个处理这个问题的 handler。SwiftNIO 为我们提供了处理这一问题的协议—— ByteToMessageDecoder:
class TimeDecoder: ByteToMessageDecoder {
typealias InboundOut = ByteBuffer
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
// Swift 的 Int 默认是 Int64
if buffer.readableBytes < 8 {
return .needMoreData
}
// 需要我们主动取出需要的部分,并调用 fireChannelRead 来让下一个 handler 处理
let slice = buffer.readSlice(length: 8)!
context.fireChannelRead(wrapInboundOut(slice))
return .continue
}
}
这个协议中,我们首先需要制定 handler 输出的类型,也就是 InboundOut,为 ByteBuffer。之后,需要覆写的是 decode 的函数。这个函数的返回值 DecodingState 可以是 .needMoreData 或 .continue,前者表示还没有收到的足够的数据,后者表示可以进行处理了。通过检查当前 buffer 中可读的数据量,我们就可以避免在数据不够的情况下进行了处理。
我们还需要处理收到的 buffer 过多的问题,这需要我们自己取出 buffer 中需要的部分(也就是代码中的 slice)然后用 fireChannelRead 来把数据传给 pipeline 中的下一个 handler,对于我们的例子,也就是传给 TimeClientHandler。
完成 TimeDecoder 后,可以像下面这样把它转化为 handler,并放在 pipeline 中 TimeClientHandler 的前面:
.channelInitializer { ch in
ch.pipeline.addHandlers([ByteToMessageHandler(TimeDecoder()),
TimeClientHandler()])
}
这样我们就解决了碎片化的问题~
本文到这里就结束了,希望它能让你对 SwiftNIO 的使用方法有了一些了解~ 不过还是要说看教程不如把代码下下来自己试试:
git clone https://github.com/swiftui-from-zero/SwiftNIO-NettyExamples.git
之后我可能还会往这个项目中加入更多的 Netty 例子进来。如果这个项目对你有帮助,不妨点个 star 呀~