Appium Server源码分析之作为Bootstrap客户端
Appium Server拥有两个主要的功能:
通过上一篇文章《Appium Server 源码分析之启动运行Express http服务器》我们分析了Appium Server是如何作为一个http服务器进行工作的。那么今天我们就要分析第二点,Appium Server是怎么作为bootstrap的客户端来向目标安卓机器的bootstrap发送命令以驱动uiautomator框架来做事情的 1. MVC设计模式中的Controller及路由Routing在我们上一篇文章描述appium server在启动http服务器的过程中,实例化appium 服务器后,下一步就是要设置好从client端过来的请求的数据路由了:
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); ...
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
appium server和bootstrap的连接在什么时候开始建立呢?其实这个需要由appium client端来进行启动。也就是说如果你只是启动appium这个应用的话,它是不会尝试和目标安卓机器的bootstrap进行连接的,而一旦我们准备运行一个脚本的时候,appium cilent端就会立刻先发送一个创建与bootstrap回话的请求“/wd/hub/session”请求过来:
这个appium client创建session的请求所带的参数就是我们脚本中设置好的capabilities,在我的例子中是这些:
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的命令任务,我们下面会一步步看这个队列是怎么建立起来的。
我们先找到routing中对应的controller方法:
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上面才会用到,只是为了保持一致性,测试安卓时还是需要传进来而已,但是无论你设置什么值都没有影响。
好,我们继续往下看,Appium类的start方法在实例化好Capabilities类后,往下有几步非常重要:
Appium.prototype.configure = function (args,desiredCaps,cb) { var deviceType; try { deviceType = this.getDeviceType(args,desiredCaps); ... } ... this.device = this.getNewDevice(deviceType); this.device.configure(args,cb); ... };
configure首先会去调用Appium类的getDeviceType这个方法,而这个方法最终又会去调用getDeviceTypeFromPlatform这个方法:
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选项的时候别填错了:
最终返回的device定义如下,其实就是一些对应的字串:
var DT_IOS = "ios",DT_SAFARI = "safari",DT_ANDROID = "android",DT_CHROME = "chrome",DT_SELENDROID = "selendroid",DT_FIREFOX_OS = "firefoxos";但是别小看这些字串,我们下面会看到就是通过他们来实例化对应的设备类的。
在获得deviceType后,configure方法下一个重要的步骤就是去根据这个deviceType字串去调用getNewDevice这个方法获得或者叫做创建一个对应的设备对象了:
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方法会初始化一大堆成员变量,在这里我们列出几个我们这篇文章需要关注的:
当中还调用了一个initQueue方法来把Appium的Work Queue给初始化了,这个Work Queue其实就是nodejs的async这个库的queue这个流程控制对象。首先,我们要搞清楚我们为什么需要用到这个queue呢?我们知道nodejs是异步执行框架的,如果不做特别的处理的话,我们一下子来了几个命令如“1.点击按钮打开新页面;2.读取新页面读取目标控件内容和预期结果比较”,那么nodejs就会两个命令同时执行,但不保证谁先占用了cpu完成操作,那么问题就来了,如果在准备执行1之前,cpu调度切换到2,那么我们的脚本就会失败,因为我们1还没有执行完,新页面还没有打开!
而async这个库的不同对象就是专门针对这些问题提供的解决办法,比如waterfals,auto,serials和queue等,其他的我暂时没有碰到,所以不清楚,至于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); };从倒数第2行我们可以看到worker是1,也就是一次过appium只会处理一个task,其他push进去的task只能等待第一个task处理完。那么这样就清楚了,我们刚才提到的两个命令,只要保证1先于2入队列,那么在异步执行的nodejs框架中就能保证1会先于2而执行。
说到执行,其实就是初始化queue的第一个匿名函数的参数,而第二个参数就是上面提到的worker的数量了,那我们继续看下这个执行函数是怎么执行的。
那么到现在为止Appium在调用start方法启动时的第一步configure算是完成了,往下就要看第二步,
3. 建立Appium Server和Bootstrap的连接
我们先进入Appium类的invoke这个方法,这个方法是在第2节初始化Appium Work Queue等configuration成功的基础上才会执行的。
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)); };这个方法很长,但做的事情也很重要:
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 () {}; };
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源码分析之启动运行》
启动好bootstrap之后,下面就会设置相应的事件处理函数来处理adb启动的bootstrap在命令行由标准输出,错误,以及退出的情况,注意这些输出跟bootstrap执行命令后返回给appium server的输出是没有半毛钱关系的,那些输出是通过socket以json的格式返回的。
这里我们看下outputStreamHandler这个收到标准输出时的事件处理函数,这个函数肯定是会触发的,你可以去adb shell到安卓机器上面通过uiatuomator命令启动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了。
为了印证,我们可以在adb shell中直接输入下面命令然后看该字串是否真的会出现:
uiautomator runtest AppiumBootstrap.jar -c io.appium.android.bootstrap.Bootstrap输入如下,果不其然:
在确保bootstrap已经启动起来后,下一个动作就是我们这一小节的目的,通过调用nodejs标准库的方法net.connect去启动与bootstrap的socket连接了:
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。
这里我们可以回顾下上面说过的打问好的是变量,会用真实的值进行替代的,如在我们的例子中:
ok,往下我们进入这个doClick方法进行分析:
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的运行原理。
第二行去调用nodejs提供的http request对象的device的click方法,这个device是什么呢?这个大家不记得的话请翻看第1节我们在初始化路由表的时候调用的getGlobalBeforeFilter方法中是把Appium对西那个的device对象赋予给了了这个request.device对象的:
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里面。
其实到了这里,click的处理已经完成任务了,因为这个queue不是由click的相关处理controller来控制的,它只是负责把这个任务加入到队列,而真正去队列取出任务进行执行的是我们上面第2节最后面提到的初始化async queue的时候的第一个参数,而那个参数是个匿名函数,没当有一个task进入队列需要执行之前都会去调用这个方法,我们回顾下:
Android.prototype.initQueue = function () { this.queue = async.queue(function (task,1); };取得传进来的task相关键值,在我们这个例子中就是:
然后调用uiatutomator的sendAction方法,并把以上两个参数给传进去:
UiAutomator.prototype.sendAction = function (action,cb) { if (typeof params === "function") { cb = params; params = {}; } var extra = {action: action,params: params}; this.sendCommand('action',extra,cb); };将参数组合成以下并传给sendCommand:
进入sendCommand:
UiAutomator.prototype.sendCommand = function (type,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灌入命令:
大家对比下图看下高亮圈住的最后一行由bootstrap打印出来的接收到的json命令字串,它们是绝对吻合的:
往下的事情就由bootstrap进行处理了,至于不清楚bootstrap怎么处理这些命令的,请查看我上一个bootstrap源码分析系列。 5. 小结通过本文我们了解了appium server作为bootstrap的客户端,同时又作为appium client的服务器端,是如何处理从client来的命令然后组建成相应的json命令字串发送到bootstrap来进行处理的:
(编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |