STF 四年前就有了吧?很遗憾,没能赶上时代的浪潮,现在只能作为债务进行偿还。我写不出这么牛的框架,只能在其基础上平凡创新,批量安装就是一个例子。当然这个也是来自部门的需要,我作为基层实施者,通过我的实践谈谈我的理解。如果有人从中受益,也算我为社区做了点贡献。
不做介绍了,这方面的内容实在太多了,自己找找多试试就行了。
这个也不打算介绍,鄙人毕竟非资深前端。除了官网介绍的几个安装组件,以下内容还是要知道的,否则改造起来就会一头雾水。(磨刀不误砍材工嘛)
NodeJS
,基石。express
,http 服务器。socket.io
,websocket 通信。protobuf
,通信。ZMQ
,消息队列,各种模式需要知道:pub/sub,push/pull,router/dealer,request/reply。angluar
,前端 mvc 框架,这些概念要清楚:module,controller,router,service,directive。bower
,包管理工具,angular 的组件多是基于 brower。wepback
,打包工具,将 nodejs 打包成 js,这里是打包前端 Angular。bluebird
,ES6 之前的支持 Promise 编码形式的框架,对该组件的使用主要是明白 STF 用它做了什么。Event
,清楚如何注册事件,出发 websocket 以及 zmq 的。很多路由多是基于 event。rethinkdb
,这个列出来是因为前端拉取设备信息是从 db 里读取出来的。(原来想修改设备表结构了)minitouch
,minicap
,minirev
,STFService.apk
等高级 linux 知识略。这个拓扑一开始肯定懵,尤其 ZMQ 不熟悉,还有 triproxy 干什么的根本不知道,所以不下一番功夫理解是有一定难度的。
如何理解这个拓扑,我是先看拓扑,再看代码,然后看部署文档 + 代码一起看,反复看,之后自己加 log 打日志,再仔细阅读文档。一定要自己阅读 deployment.md 这个文档,而且要结合 units 里面的代码看,适当看看 cli 里的参数帮助理解个 unit 之间的绑定关系。在强调一遍,deployment.md 这个文档仔细看。
拓扑图如下。(有人已经做过介绍了,可以自行搜索,我这里再重复唠叨唠叨方便记忆,算是查缺补漏吧)
我就按照从上到下的顺序说说我的理解吧,有问题欢迎指正。
(当然,这些内容也出自 deployment.md 加上我个人的一些理解)
websocket 本机实际启动了一个 websocket,这个 unit 是一个中间层,它用来将客户端的 javscript 与服务端的 zmq 以及 wire proto 进行结合。客户端实际上就是前端那个界面,前端代码对应的是 app,前端的载体及服务器对应的是 unit 的 app。zmq 和 protobuf 这个对应的比较多,多个 unit 都用到了。
这个组件没有任何逻辑,他就是个分发器,这个 triproxy 连接的是 websocket 和 processor,5.1 说了 websocket 是客户端与服务端的结合者,客户端是 app,app 就是 stf 启动的那个前端网站,所以它用来接收和发送 app 端(网站)发来的请求,然后分发给 processor 进行处理。所以这个 triproxy 是 app 端的 proxy,文档有介绍。triproxy 应该翻译成三端代理,就想文档中介绍的,它实际连接了三个端,app 端 pull,app 端 pub,processor 端 dealer。(triple 是三个一组的意思,triple-proxy 简称 triproxy,三端一个组)
processor 是 stf 的主要工作组件,它连接了 devices 和 app 之间的几乎所有的通信。devices 指代设备,app 指代前端 app,这说明它连接了前端和设备。前端其实不只 app,还有 storage,storage 负责 apk、图片的上传下载等逻辑。
又回到了 triproxy,这个是 dev 端 proxy,即设备端的 triproxy。它是用来发送和接受来自 provider 端的请求(provider 指的是你的宿主机),然后分发给 processor 组件去处理。
这个是设备 unit,文档上没有说明。看一下 units/device 的代码就知道了,主要封装了对设备操作的逻辑。譬如实际的 apk 安装,touch 操作,实时显示图像,调用 adbkit 安装 app 等逻辑。
provider 用来连接 adb,然后为每个设备启动一个 worker process。之后接受并发送从这些 processors 传过了的命令。
前面讲了大部分概念,也没有涉及具体实践。这是因为,不搞懂前面的诸多内容和原理,改造起来实在是个一团糟。没有一个清晰的思路和逻辑,只能越做越懵。
require('./device-list.css')
require('ng-file-upload') // 这里引用了angluar上传文件功能
module.exports = angular.module('device-list', [
require('angular-xeditable').name,
require('stf/device').name,
require('stf/user/group').name,
require('stf/control').name,
require('stf/common-ui').name,
require('stf/settings').name,
require('./column').name,
require('./details').name,
require('./empty').name,
require('./icons').name,
require('./stats').name,
require('./customize').name,
require('./search').name,
'angularFileUpload' // 这里添加上传文件服务
])
.config(['$routeProvider', function($routeProvider) {
$routeProvider
.when('/devices', {
template: require('./device-list.pug'),
controller: 'DeviceListCtrl'
})
}])
.run(function(editableOptions) {
// bootstrap3 theme for xeditables
editableOptions.theme = 'bs3'
})
.controller('DeviceListCtrl', require('./device-list-controller'))
$scope.installApk = function($files) {
var installDevices = []
var cbList = document.getElementsByClassName('installCheckbox')
if($files.length) {
$http.get('/api/v1/devices')
.success(function(res) {
var devices = res.devices
var len = cbList.length
;devices.forEach(function(device) {
for (var i = 0; i < len; i++) {
var cb = cbList[i]
var serial = cb.getAttribute('serial')
if (cb.checked && serial === device.serial) {
installDevices.push(device)
}
}
})
})
$upload.upload({
url: '/s/upload/apk'
, method: 'POST'
, file: $files
})
.then(function(value) {
var href = value.data.resources.file.href
$http.get(href + '/manifest')
.then(function(res) {
if (res.data.success) {
installDevices.forEach(function(installDevice) {
var control = ControlService.create(installDevice, installDevice.channel)
control.install({
href: href
, manifest: res.data.manifest
, launch: true
})
.progressed(function(result) {
})
})
} else {
throw new Error('Install apk to all selected devices failed')
}
})
.then(function() {
console.log('Install apk to all devices task succeed')
})
.catch(function(err) {
throw new Error('Install apk meet error.')
})
})
}
}
这个是前端,我随便加了一个安装 apk 的 ng 方法
.stf-device-list
.row.stf-stats-container.unselectable
.col-md-12
device-list-stats(tracker='tracker')
.row.unselectable
.col-md-12
.widget-container.fluid-height.stf-device-list-tabs
.widget-content.padded
.filtering-buttons
datalist(id='searchFields')
select(name='searchFields')
option(ng-repeat='column in columns', ng-value='column.name + ": "',
ng-bind='columnDefinitions[column.name].title | translate')
input(type='search', autosave='deviceFilter'
name='deviceFilter', ng-model='search.deviceFilter', ng-change='applyFilter(search.deviceFilter)',
ng-model-options='{debounce: 150}'
autocorrect='off', autocapitalize='off', spellcheck='false',
list='searchFields', multiple, focus-element='search.focusElement',
text-focus-select, accesskey='4').form-control.input-sm.device-search.pull-right
span.pull-right(ng-if='activeTabs.details && !$root.basicMode')
// ====== 加在这个地方了 ====
.btn-group(uib-dropdown).pull-right
button.btn.btn-sm.btn-primary-outline(type='button', ng-file-select='installApk($files)')
i.fa.fa-columns
span install apk
// ========================
button.btn.btn-sm.btn-primary-outline(type='button', uib-dropdown-toggle)
i.fa.fa-columns
span(ng-bind='"Customize"|translate')
install: DeviceInstallCell({
title: 'Install'
})
function DeviceInstallCell(options) {
return _.defaults(options, {
title: options.title
, defaultOrder: 'asc'
, build: function() {
var td = document.createElement('td')
var input = document.createElement('input')
input.type = 'checkbox'
input.className = 'installCheckbox'
td.appendChild(input)
return td
}
, update: function(td, device) {
var cb = td.firstChild
if(device.state === 'using' || device.state === 'absent') {
cb.disabled = true
} else {
cb.setAttribute('serial', device.serial)
}
return td
}
, compare: function(a, b) {
}
, filter: function(item, filter) {
}
})
}
顶层的 apk 安装逻辑在 app/control-panes/dashboard/install/install-controller.js
$scope.installFile = function($files) {
if ($files.length) {
return InstallService.installFile($scope.control, $files)
}
}
这里面调用了 InstallService 的 installFile 方法。
InstallService 在 app/component/stf/install/install-service.js。
installService.installFile = function(control, $files) {
var installation = new Installation('uploading')
$rootScope.$broadcast('installation', installation)
return StorageService.storeFile('apk', $files, {
filter: function(file) {
return /\.apk$/i.test(file.name)
}
})
.progressed(function(e) {
if (e.lengthComputable) {
installation.update(e.loaded / e.total * 100 / 2, 'uploading')
}
})
.then(function(res) {
installation.update(100 / 2, 'processing')
installation.href = res.data.resources.file.href
return $http.get(installation.href + '/manifest')
.then(function(res) {
if (res.data.success) {
installation.manifest = res.data.manifest
return control.install({
href: installation.href
, manifest: installation.manifest
, launch: installation.launch
})
.progressed(function(result) {
installation.update(50 + result.progress / 2, result.lastData)
})
}
else {
throw new Error('Unable to retrieve manifest')
}
})
})
.then(function() {
installation.okay('installed')
})
.catch(function(err) {
installation.fail(err.code || err.message)
})
}
installFile 里面广播了一个 instalation 事件,这个主要是通知前端也即更新前端进度条状态信息。
之后调用 StorageService 的 storeFile,同时过滤掉非 apk 结尾的文件。这个调用跟我上面写的上传逻辑是一样的。stf 在安装 apk 的时候,需要先将 apk 上传到/var/1 的目录里面,然后获取 apk 的安装路径,以及 manifest 信息,这些信息需要传给 controll.install 方法,下面是 StorageService 的上传文件的方法。这个方法的 upload 调用的 unit/storage/temp.js 里面的 api,temp 标识将文件上传的临时区域或临时内存等等。
service.storeFile = function(type, files, options) {
var resolver = Promise.defer()
var input = options.filter ? files.filter(options.filter) : files
if (input.length) {
$upload.upload({
url: '/s/upload/' + type
, method: 'POST'
, file: input
})
.then(
function(value) {
resolver.resolve(value)
}
, function(err) {
resolver.reject(err)
}
, function(progressEvent) {
resolver.progress(progressEvent)
}
)
}
else {
var err = new Error('No input files')
err.code = 'no_input_files'
resolver.reject(err)
}
return resolver.promise
}
上传成功后即调用 controll.install 进行安装。需要注意的是,controll 是在 device-list-controller 里面已经初始化了的。通过 ControllService.create 初始化。该方法接受两个参数,一个是 target 是设备实体信息即 device,对应数据库的 device 的一条记录。channel 是通道信息,其实就是 sub 订阅的一个 message。
install 发送 device.isntall 的 websocket 命令,scoket 出发 device.install 事件。这个 socket 在 socket 及 transaction 服务里面进行定义。
this.install = function(options) {
return sendTwoWay('device.install', options)
}
function sendTwoWay(action, data) {
var tx = TransactionService.create(target)
socket.emit(action, channel, tx.channel, data)
return tx.promise
}
websocket 的 uni 接收到前端的时间后,并将其装成 protobuf,然后推送到 zmq。
.on('device.install', function(channel, responseChannel, data) {
joinChannel(responseChannel)
push.send([
channel
, wireutil.transaction(
responseChannel
, new wire.InstallMessage(
data.href
, data.launch === true
, JSON.stringify(data.manifest)
)
)
])
})
设备端收到消息后,正是开始安装 apk。
router.on(wire.InstallMessage, function(channel, message) {
function pushApp() {
var req = request({
url: url.resolve(options.storageUrl, message.href)
})
// We need to catch the Content-Length on the fly or we risk
// losing some of the initial chunks.
var contentLength = null
req.on('response', function(res) {
contentLength = parseInt(res.headers['content-length'], 10)
})
var source = new stream.Readable().wrap(req)
var target = '/data/local/tmp/_app.apk'
return adb.push(options.serial, source, target)
.timeout(10000)
.then(function(transfer) {
var resolver = Promise.defer()
function progressListener(stats) {
if (contentLength) {
// Progress 0% to 70%
sendProgress(
'pushing_app'
, 50 * Math.max(0, Math.min(
50
, stats.bytesTransferred / contentLength
))
)
}
}
function errorListener(err) {
resolver.reject(err)
}
function endListener() {
resolver.resolve(target)
}
transfer.on('progress', progressListener)
transfer.on('error', errorListener)
transfer.on('end', endListener)
return resolver.promise.finally(function() {
transfer.removeListener('progress', progressListener)
transfer.removeListener('error', errorListener)
transfer.removeListener('end', endListener)
})
})
}
}