While we have different protocols for transmitting data over the internet, sockets are a basic building block for understanding how data is transmitted. I have come across POSIX’s (or rather BSD’s) basic stream sockets while completing the excellent Systempraktikum for my studies. I’ll probably make a post on my motivation for studying at another time.
For the Systempraktikum, the task is to communicate with a (game) server through sockets (type man socket
for details in your terminal) and a line-based protocol. Out of pure interest, I wondered how the same task would be facilitated with a more modern language, so I chose Swift to try it out. As TN3151: Choosing the right networking API points out, our best fit is the Network framework, available on all Apple platforms.
Connecting with NWConnection
The basic building block for us is NWConnection
. In comparison to socket()
, this provides us with a more general interface.
To connect to our endpoint, we need to set up the connection and specify that we want to use TCP (to mimic what socket()
does).
import Network
let connection = NWConnection(host: "example.com", port: 1337, using: .tcp)
We also need to prepare two important handlers: stateUpdateHandler
, which lets us know when the connection status changes (i.e. if it was cancelled or when it is ready) and receive
, which handles the data the socket sends to us.
Let’s start by creating the stateUpdateHandler
:
connection.stateUpdateHandler = { state in
switch state {
case .setup:
break
case .waiting(let error):
logger.info("waiting: \(error)")
case .preparing:
break
case .ready:
break
case .failed(let error):
logger.error("failed: \(error)")
// maybe disconnect here
case .cancelled:
logger.error("cancelled")
// maybe disconnect here
@unknown default:
break
}
}
The logger
was created before and instantiates a simple Logger
:
import os
let logger = Logger(subsystem: "dev.timweiss.socket", category: "network")
Continue with building the receive
handler, we’ll wrap it in a function to continuously receive data from the socket:
func startReceive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isDone, error in
if let data = data, !data.isEmpty {
let line = String(decoding: data, as: UTF8.self)
receiveBuffered(content: line)
}
if let error = error {
logger.error("Error: \(error)")
// disconnect here
return
}
if isDone {
logger.info("Server disconnected!")
// disconnect here
return
}
startReceive()
}
}
Because we want to handle what the socket sends line by line, we’re calling receiveBuffered(content:)
, which crudely consumes the data line by line and sends it off to handleLine
, which you can freely implement:
var buffer = ""
func receiveBuffered(content: String) {
logger.info("received: \(content)")
// first, add to buffer
buffer.append(content)
// check if got any complete line
if buffer.lastIndex(of: "\n") != nil {
let lines = buffer.split(separator: "\n")
let countLines = lines.count
for (index, line) in lines.enumerated() {
// incomplete last line, skipping handling for now
if (index == (countLines - 1) && !buffer.hasSuffix("\n")) { break }
handleLine(String(line))
buffer = buffer.replacingOccurrences(of: line, with: "")
}
}
}
Starting to listen
We’ve created the most important building blocks. We can finally start listening to our socket with the following lines:
startReceive()
connection.start(queue: .main)
dispatchMain()
Sending data to the socket
Of course, communication also involves sending data from time to time, so we can call send
to send something to the socket:
func send(line: String) {
let data = Data("\(line)\n".utf8)
connection.send(content: data, completion: NWConnection.SendCompletion.contentProcessed { error in
if let error = error {
logger.error("Client(Error): \(error)")
// maybe disconnect
} else {
logger.info("Client: \(line)")
}
})
}
Full handler class
Thankfully, the great Quinn “The Eskimo!” from Apple Developer Relations provided a handy example, which I’ve extended to better fit my (line-by-line handling) purposes:
import Foundation
import Network
import os
class SocketConnector {
let connection: NWConnection
let logger: Logger
init(hostName: String, port: Int) {
let host = NWEndpoint.Host(hostName)
let port = NWEndpoint.Port("\(port)")!
self.logger = Logger(subsystem: "com.example.socket", category: "network")
self.connection = NWConnection(host: host, port: port, using: .tcp)
}
func connect() {
logger.info("connecting")
self.connection.stateUpdateHandler = self.didChange(state:)
self.startReceive()
self.connection.start(queue: .main)
}
func disconnect() {
self.connection.cancel()
logger.info("did stop")
exit(EXIT_FAILURE)
}
private func didChange(state: NWConnection.State) {
switch state {
case .setup:
break
case .waiting(let error):
logger.info("waiting: \(error)")
case .preparing:
break
case .ready:
break
case .failed(let error):
logger.error("failed: \(error)")
self.disconnect()
case .cancelled:
logger.error("cancelled")
self.disconnect()
@unknown default:
break
}
}
private func startReceive() {
self.connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isDone, error in
if let data = data, !data.isEmpty {
let line = String(decoding: data, as: UTF8.self)
self.receiveBuffered(content: line)
}
if let error = error {
self.logger.error("Error: \(error)")
self.disconnect()
return
}
if isDone {
self.logger.info("Server disconnected!")
self.disconnect()
return
}
self.startReceive()
}
}
var buffer: String = ""
private func receiveBuffered(content: String) {
logger.info("received: \(content)")
// first, add to buffer
buffer.append(content)
// check if got any complete line
if buffer.lastIndex(of: "\n") != nil {
let lines = buffer.split(separator: "\n")
let countLines = lines.count
for (index, line) in lines.enumerated() {
// incomplete last line, skipping handling for now
if (index == (countLines - 1) && !buffer.hasSuffix("\n")) { break }
handleLine(String(line))
buffer = buffer.replacingOccurrences(of: line, with: "")
}
}
}
private func handleLine(_ line: String) {
// your code to handle the line
}
func send(line: String) {
let data = Data("\(line)\n".utf8)
self.connection.send(content: data, completion: NWConnection.SendCompletion.contentProcessed { error in
if let error = error {
self.logger.error("Client(Error): \(error)")
self.disconnect()
} else {
self.logger.info("Client: \(line)")
}
})
}
static func run() -> Never {
let conn = SocketConnector(hostName: "example.com", port: 1337)
conn.connect()
dispatchMain()
}
}
You can start the connection in any other place with SocketConnector.run()
. If you need to access any of its functions, however, you need to create the SocketConnector
first and then connect()
to it, but don’t forget to dispatchMain()
for it to run.