背景及原因
由于公司新入职的测试工程师配置的电脑是 Windows电脑,在测试 iOS 相关需求的时候,无法看见打印的 NSLog,测试过程中带了很大的困难,要不 iOS 开发工程师,要把日志输出到界面,这样给整体体验以及开发人员带来了极大的不方便
使用 tidevice 或者 pymobiledevice3
这两个终端脚本功能基本很相似 tidevice 仅支持到 iOS16, pymobiledevice3 支持 iOS 17+。
在 Windows 电脑上安装之后,由于是终端界面,输出的日志看上不是特别的清晰 即使可以通过 grep 过滤对测试人员看的效果也不是很友好。
于是就想如果能够把终端获取到的日志转发出去在 Web页面展示,这样就能够定制 html 样式使其内容展示更加清晰,以及添加自己想要的功能
简述 tidevice 通信原理
tidevice 主要是基于 libimobiledevice 开源库和usbmux通信协议进行封装实现
- 设备连接
-
发现设备:首先会通过 USB接口搜索链接到计算机上的 iOS 设备,利用libimobiledevice 实现设备的发现,在通过 USB协议与设备进行通信,请求连接识别到的设备
-
建立链接:tidevice 会与设备建立一个安全通信通道。主要是有 lockdown 协议,该协议用于计算机和设备之间的身份认证。连接过程中或发送一些设备信息。例如:UDID、设备信号等
- 协议交互
-
启动
**syslog_rela****y**
服务 :在建立连接后,tidevice
会请求设备启动syslog_relay
服务。syslog_relay
是 iOS 设备上的一个服务,负责收集和传输系统日志信息。tidevice
通过发送特定的命令到设备,告诉设备启动该服务。 -
建立数据通道 :启动
syslog_relay
服务后,tidevice
会与该服务建立一个数据通道。通过这个通道,tidevice
可以从设备接收系统日志数据。数据通道的建立通常基于 TCP 或 UDP 协议,具体取决于syslog_relay
服务的实现。
- 日志读取与解析
-
读取日志数据 :一旦数据通道建立成功,
tidevice
会开始从设备接收系统日志数据。这些数据是以二进制格式传输的,包含了设备的各种系统事件、应用程序日志等信息。 -
解析日志数据 :
tidevice
会对接收到的二进制日志数据进行解析,将其转换为可读的文本格式。解析过程通常涉及到对数据的解码、格式化等操作,以确保日志信息能够以清晰的方式显示在终端上。
日志转发到 Web页面
主要技术点: Python 3 + Web + websocket
Python3: 所需要的 module tidevice、asyncio(异步 I/O ) 、websockets (socket)
H5: 需要 css 、js 、 资源文件(font)
Python 作为 Server Web 作为 Client
Python服务端关键代码
python
import asyncio
import json
import re
import websockets
# from pymobiledevice3.utils import try_decode
# 常量类
class Constants:
Close = "Close"
Start = "Start"
SetFilters = "set_filters" # 新增常量,表示设置过滤关键字的操作
process = None
filter_keys = [] # 新增变量,用于存储过滤关键字
read_lock = asyncio.Lock() # 添加一个锁
match_regex = None
SYSLOG_LINE_SPLITTER = b'\n\x00'
async def data_generator():
global process
if not process:
process = await asyncio.create_subprocess_exec(
"tidevice",
"syslog",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
print("tidevice syslog 命令已启动\n")
buf = b''
try:
while True:
async with read_lock: # 使用锁确保同一时间只有一个协程可以读取数据
output = await process.stdout.read(1024)
if output == b"" and process.returncode is not None:
break
if len(output) == 0:
raise ConnectionAbortedError()
buf += output
# SYSLOG_LINE_SPLITTER is used to split each syslog line
if SYSLOG_LINE_SPLITTER in buf:
lines = buf.split(SYSLOG_LINE_SPLITTER)
# handle partial last lines
if not buf.endswith(SYSLOG_LINE_SPLITTER):
buf = lines[-1]
lines = lines[:-1]
for line in lines:
if len(line) == 0:
continue
match = None
is_valid = False
if not match_regex:
is_valid = True
else:
for r in match_regex:
match = r.search(line.decode('utf-8'))
if match:
is_valid = True
break
if is_valid:
print(f"字符串---- '{line}' 匹配成功。")
yield line.decode('utf-8')
except KeyboardInterrupt:
print("程序被手动中断,正在关闭...")
if process:
process.terminate()
await process.wait()
raise
async def websocket_handler(websocket):
global filter_keys
# 启动一个任务用于接收客户端消息
receive_task = asyncio.create_task(receive_messages(websocket))
try:
async for data in data_generator():
try:
# 将过滤后的数据实时发送给客户端
await websocket.send(data)
except websockets.exceptions.ConnectionClosed:
break
except KeyboardInterrupt:
pass
finally:
# 取消接收消息的任务
receive_task.cancel()
async def receive_messages(websocket):
global filter_keys , match_regex
try:
while True:
message = await websocket.recv()
receiveFromClientMsg(message)
try:
msg_data = json.loads(message)
if msg_data.get("action") == Constants.SetFilters:
filter_keys = msg_data.get("filter_keys", [])
match_regex = [re.compile(f'.*({r}).*', re.DOTALL) for r in filter_keys]
print(f"收到过滤关键字: {filter_keys}")
except json.JSONDecodeError:
print("接收到的消息不是有效的 JSON 格式")
except (websockets.exceptions.ConnectionClosed, KeyboardInterrupt):
pass
def receiveFromClientMsg(msg):
print(f"收到客户端消息---:{msg}")
try:
data = json.loads(msg)
# 检查 "type" 键是否存在
if "type" in data:
msgType = data["type"]
if msgType == Constants.Close:
print(f"收到客户端消息:{msgType}")
else:
print("客户端消息中不包含 'type' 键")
except json.JSONDecodeError:
print("无法解析客户端发送的 JSON 数据")
async def main():
try:
async with websockets.serve(websocket_handler, "127.0.0.1", 5001):
await asyncio.Future()
except KeyboardInterrupt:
print("程序被手动中断,正在关闭...")
if process:
process.terminate()
await process.wait()
if __name__ == "__main__":
asyncio.run(main())
Web 客户端关键代码
html
<script>
let socket;
let labelTip = document.getElementById('socket-tip');
let logArray = [];
let updateTimer = null;
let reconnectInterval;
const RECONNECT_DELAY = 10000;
let isConnected = false;
let isManuallyClosed = false;
let filterKeys = [];
const searchBox = document.getElementById('searchBox');
const tagContainer = document.getElementById('tag-container');
function connectSocket() {
const connectBtn = document.getElementById('connect-btn');
socket = new WebSocket('ws://127.0.0.1:5001');
socket.onopen = () => {
labelTip.textContent = '已连接';
labelTip.style.color = '#5FB878';
connectBtn.classList.add('layui-btn-disabled');
document.getElementById('unconnect-btn').style.display = 'inline-block';
isConnected = true;
isManuallyClosed = false;
clearInterval(reconnectInterval);
};
socket.onmessage = (event) => {
const data = event.data;
logArray.push(data);
updateList([data]);
};
socket.onerror = (error) => {
labelTip.textContent = `连接错误: ${error}`;
labelTip.style.color = '#FF5722';
};
socket.onclose = () => {
labelTip.textContent = '连接已断开';
labelTip.style.color = '#FF5722';
connectBtn.classList.remove('layui-btn-disabled');
document.getElementById('unconnect-btn').style.display = 'none';
isConnected = false;
if (!isManuallyClosed) {
labelTip.textContent = '尝试重新连接...';
reconnectInterval = setInterval(connectSocket, RECONNECT_DELAY);
}
};
}
function toggleConnection() {
if (socket && socket.readyState === WebSocket.OPEN) {
isManuallyClosed = true;
socket.close();
}
}
function sendFilterKeys() {
if (!socket || socket.readyState!== WebSocket.OPEN) {
console.error('WebSocket 未连接');
return;
}
const message = JSON.stringify({ "action": "set_filters", "filter_keys": filterKeys });
console.log(`发送的过滤关键字: ${message}`);
socket.send(message);
const filteredLogs = logArray.filter(log => shouldShowLog(log, filterKeys));
const list = document.getElementById('myList');
list.innerHTML = '';
updateList(filteredLogs);
}
</script>
遇见问题及解决
- 日志结构不完整
由于读取日志的时候是按照行读取的,如果有输入的日志都在同一行是没有问题的,有的时候咱们会打印 NSDictionary 这种数据结构的时候,里面的兼职对输出的时候会出现换行,这样就导致一个完整的日志会输出多行
- 第一次解决
根据日志规则使用正则表达式匹配指定格式,然后手动拼接字符串
python
async def data_generator():
global process
if not process:
process = await asyncio.create_subprocess_exec(
"tidevice",
"syslog",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
print("tidevice syslog 命令已启动\n")
buffer = ""
pattern = r'\d{2}:\d{2}:\d{2}'
log_pattern = re.compile(pattern)
sendLogFlag = 0
try:
while True:
async with read_lock: # 使用锁确保同一时间只有一个协程可以读取数据
output = await process.stdout.readline()
if output == b"" and process.returncode is not None:
break
tmpLog = output.decode('utf-8')
# 查找第一个时间戳的位置
match_res = log_pattern.search(tmpLog)
if match_res:
sendLogFlag += 1
if len(buffer) == 0:
buffer = tmpLog
if sendLogFlag == 2:
sendLogFlag = 1
if len(buffer) > 0:
resLog = buffer
buffer = tmpLog
# 过滤逻辑
if filter_keys: # 如果有过滤关键字
should_send = any(key in resLog for key in filter_keys)
if should_send:
yield resLog
else: # 没有过滤关键字,正常发送
yield resLog
else:
buffer += tmpLog
except KeyboardInterrupt:
print("程序被手动中断,正在关闭...")
if process:
process.terminate()
await process.wait()
raise
- 第二次解决
查看了一下 tidevice syslog 命令的源码方式解决,通过特定的分隔符进行分割,并且每次读取 1024 byte 数据内容
python
SYSLOG_LINE_SPLITTER = b'\n\x00' #查看源码发现分隔符
async def data_generator():
global process
if not process:
process = await asyncio.create_subprocess_exec(
"tidevice",
"syslog",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
print("tidevice syslog 命令已启动\n")
buf = b''
try:
while True:
async with read_lock: # 使用锁确保同一时间只有一个协程可以读取数据
output = await process.stdout.read(1024)
if output == b"" and process.returncode is not None:
break
if len(output) == 0:
raise ConnectionAbortedError()
buf += output
# SYSLOG_LINE_SPLITTER is used to split each syslog line
if SYSLOG_LINE_SPLITTER in buf:
lines = buf.split(SYSLOG_LINE_SPLITTER)
# handle partial last lines
if not buf.endswith(SYSLOG_LINE_SPLITTER):
buf = lines[-1]
lines = lines[:-1]
for line in lines:
if len(line) == 0:
continue
match = None
is_valid = False
if not match_regex:
is_valid = True
else:
for r in match_regex:
match = r.search(line.decode('utf-8'))
if match:
is_valid = True
break
if is_valid:
print(f"字符串---- '{line}' 匹配成功。")
yield line.decode('utf-8')
except KeyboardInterrupt:
print("程序被手动中断,正在关闭...")
if process:
process.terminate()
await process.wait()
raise
-
日志丢失内容
内容丢失查看了一下 Mac 的控制台打印情况,发现有些时候控制台打印也有内容丢失的情况,这个问题就没有继续解决
-
运行时间长 Web页面卡顿
运行时间长卡顿,主要是由于累计过多的数据量,上千条数据是有的,会导致内存和页面滑动卡顿。 由于作者是 iOS 工程师,对 Web 列表优化了解甚少。主要采用了,每个几百毫秒更新一次,减少一直跟新和添加来降低频次
-
中文字符显示乱码
乱码问题还没有解决,广大读者有思路解决,希望留言 乱码问题还没有解决,广大读者有思路解决,希望留言 乱码问题还没有解决,广大读者有思路解决,希望留言