stf源码解读

stf(smartphone test farm)是一个开源的远程调试工具,这个项目使用到了很多组件如屏幕信息的minicap,屏幕触控的minitouch,消息传递的zeromq等等,非常复杂。简要记录下自己二次开发的过程,值得注意的是整个项目主要就是两个开发者,而且开放了一批周边插件,很厉害。
这个项目是B/S模式,浏览器端使用的是angular1+jade编写,服务器端采用的node,构建工具使用的是webpack+gulp。安装环境要求是linux或者mac,我第一次配置感觉还挺复杂,可以看下这篇文章。我们的需求是将#/controls页面中的Devices tab的内容换成屏幕数据的直接展示,这其中遇到了机器bios连接数和多个canvas绘制的性能问题。可以看下我在github上面提的两个issue:

  1. Not enough host controller resources for new device #489
  2. A performance issue after i change some code #497
    解决方式我后面会阐述,先看下最终的效果图



    功能繁多,我以我如何实现需求进行说明。

前端代码

所有的前端代码在stf/res目录下,后端代码都在stf/lib目录下。我们能快速找到入口js文件res/app/app.js。但是却没有找到入口页面,为什么呢。因为首页是服务器端输出的。在lib/units/app/index.js中可以看到一个典型的express node服务器的代码

1
2
3
4
5
6
7
8
9
app.set('view engine', 'pug')
app.set('views', pathutil.resource('app/views')) //设置views变量,指定视图存放的目录
app.set('strict routing', true)
app.set('case sensitive routing', true)
app.set('trust proxy', true)
/**/
app.get('/', function(req, res) {
res.render('index')
})

设置了模板引擎是pug(jade改名而来),设置模板存放位置在app/views,设置默认情况下render index(也就是app/views/index.pug)。入口找到了,接下来就看路由设置,熟悉过angular1的人应该都知道路由的定义是要用到$routeProvider的。我们要修改的页面路由是http://localhost:7100/#!/devices。 然而在app.js中我们看到了这段路由定义

1
2
3
4
5
6
7
.config(function($routeProvider, $locationProvider) {
$locationProvider.hashPrefix('!')
$routeProvider
.otherwise({
redirectTo: '/devices'
})
})

这里并没有如我所想的$routeProvider.when(‘devices’)。其实这个配置被分到了每个模块之中,比如定义devices路由的在app/device-list/index.js中。相应的也看到了这个路由对象的template和controller

1
2
3
4
5
6
7
.config(['$routeProvider', function($routeProvider) {
$routeProvider
.when('/devices', {
template: require('./device-list.pug'),
controller: 'DeviceListCtrl'
})
}])

前端代码的架构大概就是这样,分出了很多模块,每个模块中包含了路由配置,模板文件,controller文件,service文件等等。

后端代码

前面说了,后端代码都在stf/lib目录下,一般启动stf服务器用的是node bin/stf local。顺着bin/stf文件很轻易找到cli.js。这个文件很重要,使用command.js定义了大量的node命令,这些命令又调用util/procutil.js创建大量进程。我们先看local参数,它的代码太长就不列出来了,可以看出它新建了如下进程,其中app值得app一侧消息处理的进程;dev指是device一侧消息的处理进程:
1.’triproxy’, ‘app001’
2.’triproxy’, ‘dev001’
3.’processor’, ‘proc001’
4.’processor’, ‘proc002’
5.’reaper’, ‘reaper001’
6.’provider’
7.auth
8.’app’
9.’api’
10.’websocket’
11.’storage’
12.’storage-plugin-apk’
13.’poorxy’
看到这里我就蒙了,还要在github上找了这张结构图,顺着结构图看才能看明白

以这个场景为例:browser中路由#/control页面中显示手机屏幕画面,滑动屏幕画面,真实设备的画面也随之改变。
1.找到该页面对应的模板是app/control-panes/device-control/device-control.pug。
2.找到屏幕显示部分是一个device-screen(device=’device’, control=’control’)的指令
3.在app/components/screen/screen-directive.js定义了device-screen.可以看到它就是一个canvas。另外在这个指令中使用了websocket连接到服务器端的socket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
var ws = new WebSocket(device.display.url)
ws.binaryType = 'blob'
/****/
ws.onmessage = (function() {
/**/
return function messageListener(message) {
screen.rotation = device.display.rotation
if (message.data instanceof Blob) {
if (shouldUpdateScreen()) {
if (scope.displayError) {
scope.$apply(function() {
scope.displayError = false
})
}
var blob = new Blob([message.data], {
type: 'image/jpeg'
})
var img = imagePool.next()
img.onload = function() {
updateImageArea(this)
g.drawImage(img, 0, 0, img.width, img.height)
// Try to forcefully clean everything to get rid of memory
// leaks. Note that despite this effort, Chrome will still
// leak huge amounts of memory when the developer tools are
// open, probably to save the resources for inspection. When
// the developer tools are closed no memory is leaked.
img.onload = img.onerror = null
img.src = BLANK_IMG
img = null
blob = null
URL.revokeObjectURL(url)
url = null
}
img.onerror = function() {
// Happily ignore. I suppose this shouldn't happen, but
// sometimes it does, presumably when we're loading images
// too quickly.
// Do the same cleanup here as in onload.
img.onload = img.onerror = null
img.src = BLANK_IMG
img = null
blob = null
URL.revokeObjectURL(url)
url = null
}
var url = URL.createObjectURL(blob)
img.src = url
}
}
else if (/^start /.test(message.data)) {
applyQuirks(JSON.parse(message.data.substr('start '.length)))
}
else if (message.data === 'secure_on') {
scope.$apply(function() {
scope.displayError = 'secure'
})
}
}
}

实际连接多台设备的时候显示每个device.display.url是不同的,其实是每台设备对应对应一个线程,这样是有道理的,多个不同的port有利于突破浏览器的同域下请求数量的限制,提高数据传输量。在onmessage也能看到大量的内存优化,提高垃圾回收效率,传输的blob数据就是屏幕数据,使用windowURL类创建bloburl,然后使用canvas drawImage绘制在画布。

那服务器端怎么建立起websocket服务器端呢?
直接看到cli.js启动的provider进程,这个代码在lib/units/provider/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var client = adb.createClient({
host: options.adbHost
, port: options.adbPort
})
/**********/
client.trackDevices().then(function(tracker) {
/**********/
tracker.on('add', filterDevice(function(device) {
/**********/
function spawn() {
/**********/
var allocatedPorts = ports.splice(0, 4)
var proc = options.fork(device, allocatedPorts.slice())
var resolver = Promise.defer()
}
}
tracker.on('change', filterDevice(function(device) {
flippedTracker.emit(device.id, 'change', device)
}))
tracker.on('remove', filterDevice(function(device) {
flippedTracker.emit(device.id, 'remove', device)
}))
}

adbkit是openstf开源的一个adb的nodejs client。client.trackDevices这个api就可以监听设备的plug和unplug消息。所以这里新设备插入之后会fork出一个新的进程,而这个命令的定义就在cli.js中的’device ‘。这个进程代码就在lib/units/device/index.js。这个文件夹下包含了大量的代码,与屏幕数据相关的代码就在/deivce/screen/stream.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
function createServer() {
/**************/
var wss = new WebSocket.Server({
port: screenOptions.publicPort
, perMessageDeflate: false
})
}
return createServer()
.then(function(wss) {
/************/
wss.on('connection', function(ws) {
/************/
function wsFrameNotifier(frame) {
return new Promise(function(resolve, reject) {
switch (ws.readyState) {
case WebSocket.OPENING:
// This should never happen.
return reject(new Error(util.format(
'Unable to send frame to OPENING client "%s"', id)))
case WebSocket.OPEN:
// This is what SHOULD happen.
ws.send(frame, {
binary: true
}, function(err) {
return err ? reject(err) : resolve()
})
return
case WebSocket.CLOSING:
// Ok, a 'close' event should remove the client from the set
// soon.
return
case WebSocket.CLOSED:
// This should never happen.
broadcastSet.remove(id)
return reject(new Error(util.format(
'Unable to send frame to CLOSED client "%s"', id)))
}
})
}
ws.on('message', function(data) {
var match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/.exec(data)
if (match) {
switch (match[2] || match[1]) {
case 'on':
broadcastSet.insert(id, {
onStart: wsStartNotifier
, onFrame: wsFrameNotifier
})
break
case 'off':
broadcastSet.remove(id)
break
case 'size':
frameProducer.updateProjection(
Number(match[3]), Number(match[4]))
break
}
}
})
}
});

代码太多我就不全列举出来了,这里就是建立了WebSocketServer与前面device-screen中的连接一一对应。发送的on,size,off等消息与这里onmessage正好也对应。broadcastSet用于距离多个连接的数据。wsFrameNotifier中ws.send(frame)就是将数据推送到device-screen中,画面也就在canvas中显示出来了

上面解释了画面怎么显示出来,再来看看滑动画面是怎么传递消息的:
前端代码还是在screen-directive.js中,直接找到mouseMoveListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**********/
control.touchMove(nextSeq(), 0, scaled.xP, scaled.yP, pressure)
if (addGhostFinger) {
control.touchDown(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure)
}
else if (deleteGhostFinger) {
control.touchUp(nextSeq(), 1)
}
else if (fakePinch) {
control.touchMove(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure)
}
control.touchCommit(nextSeq())

这个地方的control是control-service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*************/
function sendOneWay(action, data) {
socket.emit(action, channel, data)
}
function sendTwoWay(action, data) {
var tx = TransactionService.create(target)
socket.emit(action, channel, tx.channel, data)
return tx.promise
}
/*************/
this.touchMove = function(seq, contact, x, y, pressure) {
sendOneWay('input.touchMove', {
seq: seq
, contact: contact
, x: x
, y: y
, pressure: pressure
})
}

这里的socket是res/app/components/stf/socket/index.js中的socket-service。socket-service中使用socket.io。不同于screen-directive中使用的ws,这两种都是websocket通信的实现,ws模块更加贴近原生js语法。而socket.io则有自己的一套api,socket.emit就是客户端发送消息的api,而node端创建这个socketserver的代码则在lib/units/websocket/index.js中,可以很顺利的在socket.io的监听函数中找到touchMove信息监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var push = zmqutil.socket('push')
/***********/
.on('input.touchMove', function(channel, data) {
push.send([
channel
, wireutil.envelope(new wire.TouchMoveMessage(
data.seq
, data.contact
, data.x
, data.y
, data.pressure
))
])
})

在这里可以看到push socket的消息发送,这已经使用到了zeromq了,有时间另外详细讲解,我们回头看上面贴出来的stf的架构图,现在顺着看下流程。websocket进程中的push将数据推送到triproxy进程的pull中。看下lib/units/triproxy/index.js中的相关代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function proxy(to) {
return function() {
to.send([].slice.call(arguments)) //pub分发消息
}
}
// App/device output
var pub = zmqutil.socket('pub') //订阅模式
pub.bindSync(options.endpoints.pub) //http://www.cnblogs.com/dvwei/p/3608119.html。pubber绑定一个接口,等待subber的connect
log.info('PUB socket bound on', options.endpoints.pub)
// Coordinator input/output
var dealer = zmqutil.socket('dealer') //dealer是一种更高级的 请求/应答(client/server) 的socket type。它可以自由的收发消息,没有ZMQ_REP/ZMQ_REQ那样的限制。对于每一个连接,接收消息也是使用了公平队列,发送使用了循环队列(RR)。
dealer.bindSync(options.endpoints.dealer)
dealer.on('message', proxy(pub))
log.info('DEALER socket bound on', options.endpoints.dealer)
// App/device input
var pull = zmqutil.socket('pull') //管道模式
pull.bindSync(options.endpoints.pull)
pull.on('message', proxy(dealer))
log.info('PULL socket bound on', options.endpoints.pull)

pull socket收到消息之后又会调用proxy方法用dealer socket将消息传递出去,对应上图,这个消息被processor进程中的dealer处理了,看下lib/units/processor/index.js相关代码:

1
2
3
4
5
6
7
8
9
10
var appDealer = zmqutil.socket('dealer')
/***************/
var devDealer = zmqutil.socket('dealer')
appDealer.on('message', function(channel, data) {
devDealer.send([channel, data])
})
devDealer.on('message', wirerouter()
.on('')

processor中包含appDealer和devDealer两种socket。考虑到这不是设备端的消息,另外在devDealer的处理中也并没有看到对touchMove消息的处理,所以这里touchMove的消息是appDealer socket接收,devDealer socket发送出去;看图可以发现消息又被triproxy进程处理,这次是其中的dealer socket处理消息,它又将消息从pub socket中转发出来。这时候又被provider进程和provider创建的一批device子进程处理这批消息。
可以在lib/units/device/touch/index.js中最终找到,这个消息的处理

1
2
3
4
5
6
7
router
/*********/
.on(wire.TouchMoveMessage, function(channel, message) {
queue.push(message.seq, function() {
touchConsumer.touchMove(message)
})
})

简要的说了下我的理解,至于开头需求的实现,下一篇再讲