这周开发了一个ZXKitLogger_Mac,用于iOS手机和Mac端的在同一局域网下的实时日志传输和显示。比如iOS端操作时的日志,可在Mac端实时监控,仅需要局域网,而不需要通过自建服务器进行传输跳转,适合本地测试团队开发调试的场景。
演示视频:https://v.youku.com/v_show/id_XNTg5MjE4ODMzNg==.html
以该项目为例讲解,用户操作手机产生调试信息的log,开发者通过Mac客户端查看同一局域网下用户操作的实时日志。
基本概念
TCP和UDP
TCP和UDP是传输控制协议,用于数据的传输。两个的区别网上总结的文章很多,摘抄一段
- TCP面向连接(如打电话要先拨号建立连接);UDP是无连接的,即发送数据之前不需要建立连接
- TCP提供可靠的服务。也就是说,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP尽最大努力交付,即不保证可靠交付
- TCP面向字节流,实际上是TCP把数据看成一连串无结构的字节流;UDP是面向报文的UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
- 每一条TCP连接只能是点到点的;UDP支持一对一,一对多,多对一和多对多的交互通信
- TCP首部开销20字节;UDP的首部开销小,只有8个字节
- TCP的逻辑通信信道是全双工的可靠信道,UDP则是不可靠信道
UDP应用场景:
- 面向数据报方式
- 网络数据大多为短消息
- 拥有大量Client
- 对数据安全性无特殊要求
- 网络负担非常重,但对响应速度要求高
所以从这个角度来说,实时日志的传输更适合采用UDP
协议,本来我也是这么做的,结果到后面发现iOS14系统之后,UDP的传输需要向苹果提交权限申请,否则可能收不到信息。有兴趣的话可以看看这个文章 《iOS 14 UDP收不到广播处理》,所以这个Mac客户端的传输使用的是TCP
协议。当然文章后面我也会顺带说下UDP
协议的实现,如果有兴趣可以看看。
Bonjour
Bonjour是苹果为基于组播域名服务(multicast DNS)的开放性零设置网络标准所起的名字,能自动发现IP网络上的电脑、设备和服务。Bonjour 使用工业标准的 IP 协议来允许设备自动发现彼此,而不需输入IP地址或配置DNS服务器。
这样就很好理解了,因为TCP或者UDP连接都需要知道需要连接的IP和端口,让用户手动输入自然是一种方案,但是如果能自动发现那就更加便捷了,Bonjour
就是这个自动发现同一局域网的IP和端口的协议。
使用Bonjour自动查找IP和端口
使用Bonjour的设备在网络中自动传播它们自己的服务信息并聆听其它设备的服务信息,设备之间就象在打招呼,这也是命名为Bonjour(法语:你好)的原因。这样,Bonjour使局域网中的系统和服务即使在没有网络管理员的情况下很容易被找到。例如打印机,在需要打印的时候,直接查找局域网内提供打印服务的设备即可。
手机端发布日志服务
因为这个例子的情况下,Mac端是开发者所有,初衷就是最好不要让用户(手机端)去操作配置,无感知的去获取调试日志信息,所以在手机端创建日志服务,以便被Mac发现
import Foundation
import Network
import UIKit
class ZXKitLoggerBonjour: NSObject {
static let shared = ZXKitLoggerBonjour()
//创建服务
lazy var mService: NetService = {
let type = ZXKitLogger.isTCP ? "_tcp" : "_udp"
let service = NetService(domain: "\(ZXKitLogger.socketDomain).", type: "\(ZXKitLogger.socketType).\(type)", name: ZXKitLogger.userID + "-" + UIDevice.current.name, port: Int32(ZXKitLogger.socketPort))
service.schedule(in: .current, forMode: .common)
service.includesPeerToPeer = true
return service
}()
}
extension ZXKitLoggerBonjour {
//发布服务
func start() {
if let data = "ZXKitLoggerBonjour".data(using: .utf8) {
let sendData = NetService.data(fromTXTRecord: ["node" : data])
self.mService.setTXTRecord(sendData)
self.mService.publish()
}
}
}
调用ZXKitLoggerBonjour.shared.start()
发布服务,这样手机端就能被其他基于Bonjour
协议的设备查找发现了。
Mac端查找日志服务
手机端发布服务之后,就像一个打印机一样等待被操作。而当Mac端需要调试测试时,开启查找对应的设备即可
class ZXKitLoggerBonjour: NSObject {
static let shared = ZXKitLoggerBonjour()
//创建查找服务浏览器
private lazy var mBrowser: NetServiceBrowser = {
let browser = NetServiceBrowser()
browser.delegate = self
return browser
}()
//已查找到的service
private var mResolveServiceList: [NetService] = []
}
extension ZXKitLoggerBonjour {
func start() {
mBrowser.stop()
mBrowser.schedule(in: RunLoop.current, forMode: .common)
let type = ZXKitLogger.isTCP ? "_tcp" : "_udp"
mBrowser.searchForServices(ofType: "\(ZXKitLogger.socketType).\(type)", inDomain: "\(ZXKitLogger.socketDomain).")
}
}
extension ZXKitLoggerBonjour: NetServiceBrowserDelegate {
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
print("didFind service: domainName= \(service.domain), type= \(service.type), name= \(service.name), onPort= \(service.port) and hostname: \(service.hostName)");
//解析发现的service
self.mResolveServiceList.append(service)
service.delegate = self
service.resolve(withTimeout: 10)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
print("didRemove")
}
}
//该Delegate用于解析
extension ZXKitLoggerBonjour: NetServiceDelegate {
func netServiceWillPublish(_ sender: NetService) {
print("netServiceWillPublish")
}
func netService(_ sender: NetService, didNotPublish errorDict: [String : NSNumber]) {
print("didNotPublish", errorDict)
}
func netServiceDidResolveAddress(_ sender: NetService) {
print("Connecting with service: domainName= \(sender.domain), type= \(sender.type), name= \(sender.name), onPort= \(sender.port) and hostname: \(sender.hostName!)");
if let hostName = sender.hostName {
//查找到了服务
}
}
}
需要说明的是在查找到服务的回调里 func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool)
,仅仅得到的是提供服务的基本信息,例如服务类型、服务名,而在后续TCP连接时需要的host
和port
并没有在这里直接给,所以需要再继承一个NetServiceDelegate
,用于解析获得最需要的这两个信息。
这样一个基于Bonjour
的发布和查找链路、获取TCP或者UDP连接所需要的host
和port
操作就通顺了。
TCP连接并传输数据信息
通过上一步,Mac可以通过Bonjour
协议得到了手机在改局域网的host
和port
,那你就可以想象一下手机是台聊天的服务器,你向服务器发送消息,服务器得到之后进行处理,然后再回复给你消息。因为socket长时间不连接就断了,所以需要一个心跳包一直循环请求socket连接,如果手机端更适合看成服务器,如果没有接收到Mac的心跳包就断掉socket即可。所以还是先从手机端入手,建立一个服务器
,对了,这里TCP和UDP用的这个开源库CocoaAsyncSocket。
手机端
手机端看成服务器,只用等着接收socket链接,之后将日志传输给请求的那个socket即可,除了验证请求的那个socket是否是自己需要的,其他不需要做任何操作,懒懒的等着就行了。
import Foundation
import CocoaAsyncSocket
class ZXKitLoggerTCPSocketManager: NSObject {
public static let shared = ZXKitLoggerTCPSocketManager()
private lazy var serverSocket: GCDAsyncSocket = {
let queue = DispatchQueue.init(label: "zxkitlogger_socket")
let socket = GCDAsyncSocket(delegate: self, delegateQueue: queue, socketQueue: queue)
return socket
}()
private var acceptSocketList: [GCDAsyncSocket] = []
}
extension ZXKitLoggerTCPSocketManager {
func start() {
do {
try serverSocket.accept(onPort: ZXKitLogger.socketPort)
} catch {
print("accept error", error)
}
}
func send(loggerItem: ZXKitLoggerItem) {
guard let data = "\(loggerItem.mLogItemType.rawValue)|\(loggerItem.mLogDebugContent)|\(loggerItem.mCreateDate.timeIntervalSince1970)|\(loggerItem.getLogContent())".data(using: .utf8) else { return }
for socket in self.acceptSocketList {
if socket.isConnected {
socket.write(data, withTimeout: 20, tag: Int(loggerItem.mCreateDate.timeIntervalSince1970))
}
}
}
}
extension ZXKitLoggerTCPSocketManager: GCDAsyncSocketDelegate {
func socket(_ sock: GCDAsyncSocket, didAcceptNewSocket newSocket: GCDAsyncSocket) {
print("didAcceptNewSocket")
if !acceptSocketList.contains(newSocket) {
newSocket.delegate = self
acceptSocketList.append(newSocket)
}
newSocket.readData(withTimeout: -1, tag: 0)
}
func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) {
print("didReceive", String(data: data, encoding: .utf8))
sock.readData(withTimeout: -1, tag: tag)
}
}
Mac端
Mac端作为请求数据的一端,自然低声下气一点。需要拿到手机的host和port请求连接,连接好之后还要定时发送心跳包告诉服务器自己还没有结束,别断掉服务。
import Foundation
import CocoaAsyncSocket
class ZXKitLoggerTCPSocketManager: NSObject {
public static let shared = ZXKitLoggerTCPSocketManager()
private var timer: Timer?
private(set) var socketHost: String = "" //UDP的端口
private(set) var socketPort: UInt16 = 888 //UDP的端口
private var acceptSocketList: [GCDAsyncSocket] = []
private var connectSocketList: [GCDAsyncSocket] = []
}
extension ZXKitLoggerTCPSocketManager {
func start(hostName:String, port: UInt16) {
self.socketHost = hostName
self.socketPort = port
let queue = DispatchQueue.init(label: "zxkitlogger_socket")
let socket = GCDAsyncSocket(delegate: self, delegateQueue: queue, socketQueue: queue)
socket.isIPv4PreferredOverIPv6 = false
do {
try socket.connect(toHost: hostName, onPort: port, withTimeout: 20)
} catch {
print("connect error", error)
}
connectSocketList.append(socket)
//心跳包
self.sendHeartBeat()
}
func sendHeartBeat() {
print("heart beat")
timer?.invalidate()
//发送心跳包
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
guard let self = self, let data = "h".data(using: .utf8) else {
return
}
for socket in self.connectSocketList {
socket.write(data, withTimeout: 20, tag: 0)
socket.readData(withTimeout: -1, tag: 0)
}
if self.connectSocketList.isEmpty {
self.timer?.invalidate()
self.timer = nil
}
}
}
}
extension ZXKitLoggerTCPSocketManager: GCDAsyncSocketDelegate {
func socket(_ sock: GCDAsyncSocket, didAcceptNewSocket newSocket: GCDAsyncSocket) {
print("didAcceptNewSocket")
if !acceptSocketList.contains(newSocket) {
newSocket.delegate = self
acceptSocketList.append(newSocket)
}
newSocket.readData(withTimeout: -1, tag: 0)
}
func socket(_ sock: GCDAsyncSocket, didConnectToHost host: String, port: UInt16) {
print("didConnectToHost", host, port)
}
func socketDidDisconnect(_ sock: GCDAsyncSocket, withError err: Error?) {
print("socketDidDisconnect")
if let index = self.connectSocketList.firstIndex(of: sock) {
self.connectSocketList.remove(at: index)
}
}
func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) {
print("didReceive")
//接受到需要log传输的消息,记录
guard let receiveMsg = String(data: data, encoding: .utf8) else {
return
}
sock.readData(withTimeout: -1, tag: tag)
}
}
这样两端就做好了连接,服务器调用send发送消息时,客户端即可收到信息了。
UDP连接并传输数据信息
由于苹果新系统对UDP的限制,《iOS 14 UDP收不到广播处理》,所以如果不申请权限的话,不再推荐使用UDP传输。当然如果你需要的话可以继续看。
UDP的实现思路和TCP的是一样的,只是传输的协议不同,因为发送数据之前不需要建立连接,所以主要是Mac端不一样,手机端还是创建一个UDP的服务,然后懒洋洋的等着就行了
手机端
class ZXKitLoggerUDPSocketManager: NSObject {
public static let shared = ZXKitLoggerUDPSocketManager()
private lazy var serverSocket: GCDAsyncUdpSocket = {
let queue = DispatchQueue.init(label: "zxkitlogger_socket")
let socket = GCDAsyncUdpSocket(delegate: self, delegateQueue: queue, socketQueue: queue)
return socket
}()
//client address
private var addressList: [Data] = []
}
extension ZXKitLoggerUDPSocketManager {
func start() {
if serverSocket.isConnected() {
print("isConnected")
return
}
do {
try serverSocket.bind(toPort: ZXKitLogger.socketPort)
} catch {
printError("socket.bind error: \(error.localizedDescription)")
}
do {
try serverSocket.beginReceiving()
} catch {
printError("socket.beginReceiving error: \(error.localizedDescription)")
}
}
func send(loggerItem: ZXKitLoggerItem) {
guard !self.addressList.isEmpty else { return }
//如果有订阅的才发送
for address in addressList {
guard let host = GCDAsyncUdpSocket.host(fromAddress: address) else { continue }
let port = GCDAsyncUdpSocket.port(fromAddress: address)
if let data = "\(loggerItem.mLogItemType.rawValue)|\(loggerItem.mLogDebugContent)|\(loggerItem.mCreateDate.timeIntervalSince1970)|\(loggerItem.getLogContent())".data(using: .utf8) {
serverSocket.send(data, toHost: host, port: port, withTimeout: 60, tag: Int(loggerItem.mCreateDate.timeIntervalSince1970))
}
}
}
}
extension ZXKitLoggerUDPSocketManager: GCDAsyncUdpSocketDelegate {
func udpSocket(_ sock: GCDAsyncUdpSocket, didConnectToAddress address: Data) {
print("address")
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didNotConnect error: Error?) {
print("didNotConnect", error)
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didSendDataWithTag tag: Int) {
print("didSend")
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didNotSendDataWithTag tag: Int, dueToError error: Error?) {
print("didNotSendDataWithTag", error)
}
func udpSocketDidClose(_ sock: GCDAsyncUdpSocket, withError error: Error?) {
print("udpSocketDidClose", error)
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didReceive data: Data, fromAddress address: Data, withFilterContext filterContext: Any?) {
printLog("didReceive", String(data: data, encoding: .utf8), GCDAsyncUdpSocket.host(fromAddress: address), GCDAsyncUdpSocket.port(fromAddress: address))
//接受到需要log传输的消息,记录
guard let receiveMsg = String(data: data, encoding: .utf8), receiveMsg == "ZXKitLogger_auth" else {
return
}
//验证一下请求连接的socket,添加到address,重复的ip不添加
if self.addressList.contains(where: { data in
GCDAsyncUdpSocket.host(fromAddress: data) == GCDAsyncUdpSocket.host(fromAddress: address)
}) {
return
}
self.addressList.append(address)
}
}
Mac端
Mac端主要就是查找到手机之后,可以给手机发送一下验证的信息,让服务器(手机)知道有这个要接收的IP,然后也等着接收信息就行了,不再需要一个心跳包持续连接
import Foundation
import CocoaAsyncSocket
class ZXKitLoggerUDPSocketManager: NSObject {
private var socketHost: String = "" //UDP的端口
private var socketPort: UInt16 = 888 //UDP的端口
private lazy var clientSocket: GCDAsyncUdpSocket = {
let queue = DispatchQueue.init(label: "zxkitlogger_socket")
let socket = GCDAsyncUdpSocket(delegate: self, delegateQueue: queue, socketQueue: queue)
return socket
}()
func start(hostName:String, port: UInt16) {
self.socketHost = hostName
self.socketPort = port
do {
try clientSocket.bind(toPort: self.socketPort)
} catch {
print("socket.bind error: \(error.localizedDescription)")
}
do {
try clientSocket.beginReceiving()
} catch {
print("socket.beginReceiving error: \(error.localizedDescription)")
}
//发送一条认证信息
clientSocket.send("ZXKitLogger_auth".data(using: .utf8)!, toHost: self.socketHost, port: self.socketPort, withTimeout: 600, tag: 1)
}
}
extension ZXKitLoggerUDPSocketManager: GCDAsyncUdpSocketDelegate {
func udpSocket(_ sock: GCDAsyncUdpSocket, didConnectToAddress address: Data) {
print("address")
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didNotConnect error: Error?) {
print("didNotConnect", error)
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didSendDataWithTag tag: Int) {
print("didSend")
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didNotSendDataWithTag tag: Int, dueToError error: Error?) {
print("didNotSendDataWithTag", error)
}
func udpSocket(_ sock: GCDAsyncUdpSocket, didReceive data: Data, fromAddress address: Data, withFilterContext filterContext: Any?) {
print("didReceive", String(data: data, encoding: .utf8))
//接受到需要log传输的消息,记录
guard let receiveMsg = String(data: data, encoding: .utf8) else {
return
}
}
}
UDP这部分因为项目限制,所以没有再投入更多的精力,主要是说一下思路。如果有bug可以尝试下网络搜索。
其他注意事项
因为安全方面考虑,苹果也做了部分限制,需要在项目中配置
1、Mac端需要在项目设置中:Project > Signing&Capabilities 中开启NetWork,勾选Incoming和Outgoing
2、iOS端需要在项目info.plist
增加局域网描述,和Bonjour
的服务字段。Bonjour
的参数和创建服务时mService的type一致,例如
<key>NSBonjourServices</key>
<array>
<string>_zxkitlogger._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>查找本地网络以便使用Bonjour功能</string>
3、建议在接收的socket时,增加一层消息验证之类的安全措施,不过因为是在局域网,所以泄露的几率也不大
4、可以体验下ZXKitLogger_Mac配合ZXKitLogger,如果有建议&Bug可以提给我。欢迎给个star
参考文章
- TCP和UDP的最完整的区别
- ios 开发 NSNetServicesErrorCode = "-72008";
- iOS近场通信-蓝牙、WiFi开发
- iOS Bonjour的使用-本地通信/智能交互
- 基于CocoaAsyncSocket实现即时通讯
- iOS 14 UDP收不到广播处理
版权属于:东哥笔记 - DongGe.org
本文链接:https://dongge.org/blog/1203.html
自2017年12月26日起,『转载以及大段采集进行后续编辑』须注明本文标题和链接!否则禁止所有转载和采集行为!
1 条评论
如果有bug可以尝试下网络搜索