Appium Server源码分析之作为Bootstrap客户端
Appium Server拥有两个主要的功能:
- 它是个http服务器,它专门接收从客户端通过基于http的REST协议发送过来的命令
- 他是bootstrap客户端:它接收到客户端的命令后,需要想办法把这些命令发送给目标安卓机器的bootstrap来驱动uiatuomator来做事情
通过上一篇文章《Appium Server 源码分析之启动运行Express http服务器》我们分析了Appium Server是如何作为一个http服务器进行工作的。那么今天我们就要分析第二点,Appium Server是怎么作为bootstrap的客户端来向目标安卓机器的bootstrap发送命令以驱动uiautomator框架来做事情的
1. MVC设计模式中的Controller及路由Routing
在我们上一篇文章描述appium server在启动http服务器的过程中,实例化appium 服务器后,下一步就是要设置好从client端过来的请求的数据路由了:
- var main = function (args, readyCb, doneCb) {
- ...
- routing(appiumServer);
- ...
- }
nodejs的express架构就是采用了MVC框架的,所以这里才有了我们的Routing,我们先找到对应的Routing文件,然后进去看看。我们先看main.js的比较前的变量定义部分:
var http = require('http') , express = require('express') , ... , routing = require('./routing.js')
可以看到routing是在main.js所在目录的routing.js文件里导出来的,我们打开该文件:
var controller = require('./controller.js'); module.exports = function (appium) { var rest = appium.rest; var globalBeforeFilter = controller.getGlobalBeforeFilter(appium); // Make appium available to all REST http requests. rest.all('/wd/*', globalBeforeFilter); routeNotYetImplemented(rest); rest.all('/wd/hub/session/*', controller.sessionBeforeFilter); rest.get('/wd/hub/status', controller.getStatus); rest.post('/wd/hub/session', controller.createSession); rest.get('/wd/hub/session/:sessionId?', controller.getSession); rest.delete('/wd/hub/session/:sessionId?', controller.deleteSession); rest.get('/wd/hub/sessions', controller.getSessions); rest.get('/wd/hub/session/:sessionId?/context', controller.getCurrentContext); rest.post('/wd/hub/session/:sessionId?/context', controller.setContext); rest.get('/wd/hub/session/:sessionId?/contexts', controller.getContexts); rest.post('/wd/hub/session/:sessionId?/element', controller.findElement); rest.post('/wd/hub/session/:sessionId?/elements', controller.findElements); rest.post('/wd/hub/session/:sessionId?/element/:elementId?/value', controller.setValue); rest.post('/wd/hub/session/:sessionId?/element/:elementId?/click', controller.doClick); ...
- 路由一开始就指定了我们MVC的处理http客户端过来的Controller是controller.js这个javascript脚本
- 然后从上面调用穿进来的appiumServer中取出express实例并赋给rest这个变量
- 然后设置gloabalBeforeFilter这个控制器来处理客户端过来的而在这个routing文件中没有定义的请求的情况
- 在往下就是定义客户端过来的各种请求的controller处理方法了,比如最下面那个客户端请求对一个控件进行点击操作。这里就不一一列举了。这里要注意的是其中大问号的都是代表变量,真正的值是客户端传送过来的时候赋予的,所以解析的时候可以直接取elementId就能得到真正的值了。
exports.getGlobalBeforeFilter = function (appium) { return function (req, res, next) { req.appium = appium; req.device = appium.device; ... }; };就是把appium的device这个成员变量赋予给了nodejs提供的req这个request的device这个变量,当前在没有启动一个与boostrap的session前这个值为null,但往后appium.device将会赋予android这个对象,而因为上面代码的赋值是对象赋值,所以在javascript会是指针传递,那么也就是说最后appium.device被赋值了android对象就相当于req.device被赋予了android这个对象。这个是后话,下面你会跟到这些赋值的变化的了。
2. 创建Appium任务队列Work Queue
DesiredCapabilities capabilities = new DesiredCapabilities(); capabilities.setCapability("deviceName","Android"); capabilities.setCapability("appPackage", "com.example.android.notepad"); capabilities.setCapability("appActivity", "com.example.android.notepad.NotesList"); driver = new AndroidDriver(new URL("http://127.0.0.1:4723/wd/hub"), capabilities); }往下我们就跟踪下创建session在routing路由表里对应的controller是怎么实现和bootstrap的通信的,但是其实在真正实现通信之前,appium需要先去初始化一个async库的queue队列来排队我们需要发送到bootstrap的命令任务,我们下面会一步步看这个队列是怎么建立起来的。
rest.post('/wd/hub/session', controller.createSession);
处理函数是controller的createSession这个方法,我们进去看看:
exports.createSession = function (req, res) { if (typeof req.body === 'string') { req.body = JSON.parse(req.body); } ... req.appium.start(req.body.desiredCapabilities, function (err, instance) { ... }它会先取得http client发过来的request的body,也就是上面包含我们的capabilities的那一串键值对组成的字符串了。然后将这些键值对转换成JSON格式,最后就以这些capabilities作为参数来调用req.appium的start方法,还记得req.appium是在哪里赋值的吗?对,就在上面初始化routing的时候调用的‘controller.getGlobalBeforeFilter“这个方法里面了,初始化成我们在启动http服务器时创建的那个appium server了(如果不清楚appium server是在启动http服务器过程中什么时候创建的,请查看上一篇文章)。好我们跳进该方法继续往下看:
Appium.prototype.start = function (desiredCaps, cb) { var configureAndStart = function () { this.desiredCapabilities = new Capabilities(desiredCaps); this.updateResetArgsFromCaps(); this.args.webSocket = this.webSocket; // allow to persist over many sessions this.configure(this.args, this.desiredCapabilities, function (err) { if (err) { logger.debug("Got configuration error, not starting session"); this.cleanupSession(); cb(err, null); } else { this.invoke(cb); } }.bind(this)); }.bind(this); if (this.sessionId === null) { configureAndStart(); } else if (this.sessionOverride) { logger.info("Found an existing session to clobber, shutting it down " + "first..."); this.stop(function (err) { if (err) return cb(err); logger.info("Old session shut down OK, proceeding to new session"); configureAndStart(); }); } else { return cb(new Error("Requested a new session but one was in progress")); } };代码开始就是些根据传进来的capabilites参数初始化一个Capabilities对象之类的,这里Capabilities这个类值得一提的地方是它定义了一系列的capability,其中有一类是我们在测试脚本中必须填写的:
var requiredCaps = [ 'platformName' , 'deviceName' ];也就是说其他的capability我们在脚本中可以根据需求取配置填写,但是这两个是必须的,硬性要求的。其实根据我对现有源码的研究,在安卓上面只有platformName是必须的,deviceName只有在ios上面才会用到,只是为了保持一致性,测试安卓时还是需要传进来而已,但是无论你设置什么值都没有影响。
- 第一步:通过调用configure方法来初始化Android设备类,Android设备类的实例维护的Appium Work Queue
- 第二步:通过调用invoke方法建立好uiautomator类与bootstrap的连接
Appium.prototype.configure = function (args, desiredCaps, cb) { var deviceType; try { deviceType = this.getDeviceType(args, desiredCaps); ... } ... this.device = this.getNewDevice(deviceType); this.device.configure(args, desiredCaps, cb); ... };
Appium.prototype.getDeviceTypeFromPlatform = function (caps) { var device = null; switch (caps) { case 'ios': device = DT_IOS; break; case 'android': device = DT_ANDROID; break; case 'firefoxos': device = DT_FIREFOX_OS; break; } return device; };可以看到我们支持的platform就三个,所以我们在测试脚本设置capabilities选项的时候别填错了:
- ios
- android
- firefox
var DT_IOS = "ios" , DT_SAFARI = "safari" , DT_ANDROID = "android" , DT_CHROME = "chrome" , DT_SELENDROID = "selendroid" , DT_FIREFOX_OS = "firefoxos";但是别小看这些字串,我们下面会看到就是通过他们来实例化对应的设备类的。
Appium.prototype.getNewDevice = function (deviceType) { var DeviceClass = (function () { switch (deviceType) { case DT_IOS: return IOS; case DT_SAFARI: return Safari; case DT_ANDROID: return Android; case DT_CHROME: return Chrome; case DT_SELENDROID: return Selendroid; case DT_FIREFOX_OS: return FirefoxOs; default: throw new Error("Tried to start a device that doesn't exist: " + deviceType); } })(); return new DeviceClass(); };DeviceClass这个变量是通过匿名函数返回的一个别的地方export出来的一个对象,比如以DT_ANDROID这个deviceType为例子,它返回的是Android,而Android的定义是:
, Android = require('./devices/android/android.js')而android.js导出来的其实就是Android这个类:
var Android = function () { this.init(); }; ... module.exports = Android;最终getNewDevice这个方法通过new DeviceClass()对设备类进行实例化,事实上就是相当于new Android(),在我们这个例子中。那么在实例化Android这个设备类的时候其构造函数调用init方法又做了什么事情呢?
Android.prototype.init = function () { ... this.args.devicePort = 4724; ... this.initQueue(); ... this.adb = null; ... this.uiautomator = null; ... }Android类的init方法会初始化一大堆成员变量,在这里我们列出几个我们这篇文章需要关注的:
- args.devicePort:指定我们pc端forward到bootstrap的端口号4724
- adb:Android Debug Bridge实例,初始化为null,往后很进行设置
- uiautomator:初始化为空,往后会设置成uiautomator类的实例,转本处理往bootstrap发送接收命令的事情
- queue: 是一个串行的消息队列,通过限制了worker数量,不再一次性全部执行。当worker数量不够用时,新加入的任务将会排队等候,直到有新的worker可用。
Android.prototype.initQueue = function () { this.queue = async.queue(function (task, cb) { var action = task.action, params = task.params; this.cbForCurrentCmd = cb; if (this.adb && !this.shuttingDown) { this.uiautomator.sendAction(action, params, function (response) { this.cbForCurrentCmd = null; if (typeof cb === 'function') { this.respond(response, cb); } }.bind(this)); } else { this.cbForCurrentCmd = null; var msg = "Tried to send command to non-existent Android device, " + "maybe it shut down?"; if (this.shuttingDown) { msg = "We're in the middle of shutting down the Android device, " + "so your request won't be executed. Sorry!"; } this.respond({ status: status.codes.UnknownError.code , value: msg }, cb); } }.bind(this), 1); };从倒数第2行我们可以看到worker是1,也就是一次过appium只会处理一个task,其他push进去的task只能等待第一个task处理完。那么这样就清楚了,我们刚才提到的两个命令,只要保证1先于2入队列,那么在异步执行的nodejs框架中就能保证1会先于2而执行。
- 首先它会从push进来的task中取出action和params两个参数(其实这两个就是要一个命令的主要组成部分),我们在第4小节会描述一个task是怎么push进来的
- 然后到最重要的一行代码就是调用了uiautomator的sendAction方法,当然这里我们还在初始化阶段,所以并没有任务可以执行。我们在第4小节会描述action是怎么发送出去的
3. 建立Appium Server和Bootstrap的连接
Appium.prototype.invoke = function (cb) { this.sessionId = UUID.create().hex; logger.debug('Creating new appium session ' + this.sessionId); if (this.device.args.autoLaunch === false) { ... } else { // the normal case, where we launch the device for folks var onStart = function (err, sessionIdOverride) { if (sessionIdOverride) { this.sessionId = sessionIdOverride; logger.debug("Overriding session id with " + JSON.stringify(sessionIdOverride)); } if (err) return this.cleanupSession(err, cb); logger.debug("Device launched! Ready for commands"); this.setCommandTimeout(this.desiredCapabilities.newCommandTimeout); cb(null, this.device); }.bind(this); this.device.start(onStart, _.once(this.cleanupSession.bind(this))); } };onStart是启动连接上设备后的回调,重要的是最后面的一行,从上一节我们知道appium现在保存的设备类其实已经是Android类了,它调用device的start其实就是调用了Android实例的start,我们跳到/devices/android/android.js看下这个start做了什么:
Android.prototype.start = function (cb, onDie) { this.launchCb = cb; this.uiautomatorExitCb = onDie; logger.info("Starting android appium"); if (this.adb === null) { this.adb = new ADB(this.args); } if (this.uiautomator === null) { this.uiautomator = new UiAutomator(this.adb, this.args); this.uiautomator.setExitHandler(this.onUiautomatorExit.bind(this)); } logger.debug("Using fast reset? " + this.args.fastReset); async.series([ this.prepareDevice.bind(this), this.packageAndLaunchActivityFromManifest.bind(this), this.checkApiLevel.bind(this), this.pushStrings.bind(this), this.processFromManifest.bind(this), this.uninstallApp.bind(this), this.installAppForTest.bind(this), this.forwardPort.bind(this), this.pushAppium.bind(this), this.initUnicode.bind(this), this.pushSettingsApp.bind(this), this.pushUnlock.bind(this), this.uiautomator.start.bind(this.uiautomator), this.wakeUp.bind(this), this.unlock.bind(this), this.getDataDir.bind(this), this.setupCompressedLayoutHierarchy.bind(this), this.startAppUnderTest.bind(this), this.initAutoWebview.bind(this) ], function (err) { if (err) { this.shutdown(function () { this.launchCb(err); }.bind(this)); } else { this.didLaunch = true; this.launchCb(null, this.proxySessionId); } }.bind(this)); };这个方法很长,但做的事情也很重要:
- 建立adb,代码跟踪进去可以见到建立adb不是在appium server本身的源码里面实现的,调用的是另外一个叫"appium-adb"的库,我手头没有源码,所以就不去看它了,但是不用看我都猜到是怎么回事,无非就是像本人以前分析《MonkeyRunner源码分析之启动》时分析chimpchat一样,把adb给封装一下,然后提供一些额外的方便使用的方法出来而已
- 创建uiautomator这个底层与bootstrap和目标机器交互的类的实例,既然需要和目标机器交互,那么刚才的adb时必须作为参数传进去的了。大家还记得上面提到的在初始化Android这个设备类的时候uiautomator这个变量时设置成null的吧,其实它是在这个时候进行实例化的。这里有一点需要注意的是systemPort这个参数,appium server最终与bootstrap建立的socket连接的端口就是它,现在传进来的就是
var UiAutomator = function (adb, opts) { this.adb = adb; this.proc = null; this.cmdCb = null; this.socketClient = null; this.restartBootstrap = false; this.onSocketReady = noop; this.alreadyExited = false; this.onExit = noop; this.shuttingDown = false; this.webSocket = opts.webSocket; this.systemPort = opts.systemPort; this.resendLastCommand = function () {}; };
- 往下我们会看到nodejs流程控制类库async的另外一个对象series,这个有别于上面用到的queue,因为queue时按照worker的数量来看同时执行多少个task的,而series时完全按顺序执行的,所以叫做series
- 这个series 要做的事情就多了,主要就是真正运行时的环境准备,比如检查目标及其api的level是否大于17,安装测试包,bootstrap端口转发,开始测试目标app等,如果每个都进行分析的话大可以另外开一个系列了。这个不是不可能,今后看我时间吧,这里我就分析跟我们这个小节密切相关的uiautomator.start这个方法,其实其他的大家大可以之后自行分析。
this.uiautomator.start.bind(this.uiautomator),大家可以看到bind的参数是当前这个Android实例的uiautomator这个对象,所以最终start这个方法里面用到的所有的this指得都是Android这个实例的uiautomator对象。
UiAutomator.prototype.start = function (readyCb) { logger.info("Starting App"); this.adb.killProcessesByName('uiautomator', function (err) { if (err) return readyCb(err); logger.debug("Running bootstrap"); var args = ["shell", "uiautomator", "runtest", "AppiumBootstrap.jar", "-c", "io.appium.android.bootstrap.Bootstrap"]; this.alreadyExited = false; this.onSocketReady = readyCb; this.proc = this.adb.spawn(args); this.proc.on("error", function (err) { logger.error("Unable to spawn adb: " + err.message); if (!this.alreadyExited) { this.alreadyExited = true; readyCb(new Error("Unable to start Android Debug Bridge: " + err.message)); } }.bind(this)); this.proc.stdout.on('data', this.outputStreamHandler.bind(this)); this.proc.stderr.on('data', this.errorStreamHandler.bind(this)); this.proc.on('exit', this.exitHandler.bind(this)); }.bind(this)); };UiAutomator的实例在启动的时候会先通过传进来的adb实例spawn一个新进程来把bootstrap给启动起来,启动的详细流程这里就不谈了,大家可以看本人之前的文章《Appium Android Bootstrap源码分析之启动运行》
UiAutomator.prototype.outputStreamHandler = function (output) { this.checkForSocketReady(output); this.handleBootstrapOutput(output); };很简短,仅仅两行,第二行就是刚才说的去处理bootstrap本应打印到命令行的其他标准输出。而第一行比较特殊,注意它的输入参数也是ouput这个标准输出数据,我们进去看看它要干嘛:
UiAutomator.prototype.checkForSocketReady = function (output) { if (/Appium Socket Server Ready/.test(output)) { this.socketClient = net.connect(this.systemPort, function () { this.debug("Connected!"); this.onSocketReady(null); }.bind(this)); this.socketClient.setEncoding('utf8'); ... } };它做的第一件事情就是去检查启动bootstrap 的时候标准输出有没有"Appium Socket Server Ready"这个字符串,有就代表bootstrap已经启动起来了,没有的话这个函数就直接finish了。
uiautomator runtest AppiumBootstrap.jar -c io.appium.android.bootstrap.Bootstrap输入如下,果不其然:
this.socketClient = net.connect(this.systemPort, function () {UiAutomator实例,也就是Android类实例的uiautomator对象,所拥有的socketClient就是appium server专门用来与bootstrap通信的实例。这里的this.systemPort就是4724,至于为什么,我就不回答了,大家去再跟踪细点就知道了
4. 往Bootstrap发送命令
既然任务队列已经初始化,与boostrap通信的socket也建立妥当了,那么现在就是时候看一个实例来看下appium server是如何在接受到appium client的rest命令后,往bootstrap那边去灌命令的了。开始之前我们先看下debug log,看下appium client端发送过来的命令及相关的输出:
我们可以看到client端发送过来的命令是:
info: --> POST /wd/hub/session/ae82c5ae-76f8-4f67-9312-39e4a52f5643/element/2/click {"id":"2"}那么我们参照路由routing表查找到对应的处理controller:
rest.post('/wd/hub/session/:sessionId?/element/:elementId?/click', controller.doClick);对应的处理方法是controller.doClick。注意这里前面一部分是http request的body,后面一部分是params。
- sessionId:ae82c5ae-76f8-4f67-9312-39e4a52f5643
- elementId: 2
exports.doClick = function (req, res) { var elementId = req.params.elementId || req.body.element; req.device.click(elementId, getResponseHandler(req, res)); };第一行是把client传送过来的控件在bootstrap中的控件哈稀表key解析出来,至于为什么需要传两个一样的值然后进行或,我还没有看appium client端的代码,所以这里解析不了,也许今后有时间分析client代码的话会给大家说明白。但在这里你只需要知道这个elementId是怎么回事做什么用的就够了,这个不影响我们去理解appium server的运行原理。
req.device = appium.device;而appium.device对象又是在第2节在start Appium实例时通过其configuration等一系列调用中初始化的,最终在我们安卓环境中就是初始化成Android这个设备类的实例,而Android这个类又extend了android-controller.js里面的所有方法:
... , androidController = require('./android-controller.js') ... _.extend(Android.prototype, androidController);所以最终的click落实到了android-controller.js里面也就是androidController对象的click方法:
androidController.click = function (elementId, cb) { this.proxy(["element:click", {elementId: elementId}], cb); };只有一行,调用的是proxy这个方法,跳进去:
exports.proxy = function (command, cb) { logger.debug('Pushing command to appium work queue: ' + JSON.stringify(command)); this.push([command, cb]); };所做的事情就是直接把刚才那传命令作为一个task来push到上面提到的async.queue这个Apium Work Queue里面。
Android.prototype.initQueue = function () { this.queue = async.queue(function (task, cb) { var action = task.action, params = task.params; this.cbForCurrentCmd = cb; if (this.adb && !this.shuttingDown) { this.uiautomator.sendAction(action, params, function (response) { this.cbForCurrentCmd = null; if (typeof cb === 'function') { this.respond(response, cb); } }.bind(this)); } else { this.cbForCurrentCmd = null; var msg = "Tried to send command to non-existent Android device, " + "maybe it shut down?"; if (this.shuttingDown) { msg = "We're in the middle of shutting down the Android device, " + "so your request won't be executed. Sorry!"; } this.respond({ status: status.codes.UnknownError.code , value: msg }, cb); } }.bind(this), 1); };取得传进来的task相关键值,在我们这个例子中就是:
- action:"element:click"
- params:"{elementId:2"}
UiAutomator.prototype.sendAction = function (action, params, cb) { if (typeof params === "function") { cb = params; params = {}; } var extra = {action: action, params: params}; this.sendCommand('action', extra, cb); };将参数组合成以下并传给sendCommand:
- 参数1:‘action’
- 参数2:‘{action:action,parames:{elementId:2}‘
UiAutomator.prototype.sendCommand = function (type, extra, cb) { ... else if (this.socketClient) { ... var cmd = {cmd: type}; cmd = _.extend(cmd, extra); var cmdJson = JSON.stringify(cmd) + "\n"; this.cmdCb = cb; var logCmd = cmdJson.trim(); if (logCmd.length > 1000) { logCmd = logCmd.substr(0, 1000) + "..."; } this.debug("Sending command to android: " + logCmd); this.socketClient.write(cmdJson); } ... }根据传进来的参数,最终组合成以下参数通过第3节初始化好的与bootstrap进行socket通信的socketClient来往bootstrap灌入命令:
- {"cmd":"action","action":element:click","params":{"elementId":"2"}}
往下的事情就由bootstrap进行处理了,至于不清楚bootstrap怎么处理这些命令的,请查看我上一个bootstrap源码分析系列。
5. 小结
通过本文我们了解了appium server作为bootstrap的客户端,同时又作为appium client的服务器端,是如何处理从client来的命令然后组建成相应的json命令字串发送到bootstrap来进行处理的:- 初始化REST路由表来接收appium client过来的REST命令调用并分发给相应的controller方法来进行处理
- appium client发送命令之前会先触发一个创建session的请求,在路由表表现为“rest.post(‘/wd/hub/session‘, controller.createSession);”。对应的controller处理方法会开始初始化与bootstrap的连接
- 创建的session的过程会调用Appium类的start方法然后机型一系列的动作:
- 初始化设备类Android
- 用Async库的queue对象初始化Appium的Work Queue
- 创建adb实例并启动adb
- 安装测试apk
- 通过adb启动bootstrap
- 建立Appium Server和bootstrap的socket连接
- ...,等等等等
- 创建好连接后Appium Server就会接收client过来的命令,比如click,然后对应的controller处理方法就会建立命令对应的task并把它push到async queue里面
- 一旦有新task进入到async queue,对应的回调函数就会触发,开始往已经建立的与bootstrap的socket连接发送json字串命令
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。