Appium Appium 入门到原理之 Appium Server 源码分析之启动运行 Express http 服务器

TechoGoGoGo · 2015年02月04日 · 最后由 李腾 回复于 2017年05月16日 · 1692 次阅读

通过上一个系列 Appium Android Bootstrap 源码分析我们了解到了 appium 在安卓目标机器上是如何通过 bootstrap 这个服务来接收 appium 从 pc 端发送过来的命令,并最终使用 uiautomator 框架进行处理的。大家还没有这方面的背景知识的话建议先去看一下,以下列出来方便大家参考:
《Appium Android Bootstrap 源码分析之简介》
《Appium Android Bootstrap 源码分析之控件 AndroidElement》
《Appium Android Bootstrap 源码分析之命令解析执行》
《Appium Android Bootstrap 源码分析之启动运行》
那么我们知道了目标机器端的处理后,我们理所当然需要搞清楚 bootstrap 客户端,也就是 Appium Server 是如何工作的,这个就是这个系列文章的初衷。
Appium Server 其实拥有两个主要的功能:
它是个 http 服务器,它专门接收从客户端通过基于 http 的 REST 协议发送过来的命令
他是 bootstrap 客户端:它接收到客户端的命令后,需要想办法把这些命令发送给目标安卓机器的 bootstrap 来驱动 uiatuomator 来做事情
我们今天描述的就是第一点。大家先看下我以前画的一个 appium 架构图好有个基本概念:Appium Server 大概是在哪个位置进行工作的

同时我们也先看下 Appium Server 的源码布局,后有一个基本的代码结构概念:

开始之前先声明一下,因为 appium server 是基于当今热本的 nodejs 编写的,而我本人并不是写 javascript 出身的,只是在写这篇文章的时候花了几个小时去了解了下 javascript 的语法,但是我相信语言是相同的,去看懂这些代码还是没有太大问题的。但,万一当中真有误导大家的地方,还敬请大家指出来,以免祸害读者...

1.运行参数准备

Appium 服务器启动的入口就在 bin 下面的 appium.js 这个文件里面.在一开始的时候这个 javascript 就会先去导入必须的模块然后对启动参数进行初始化:
var net = require('net')
, repl = require('repl')
, logFactory = require('../lib/server/logger.js')
, parser = require('../lib/server/parser.js');

require('colors');

var args = parser().parseArgs();
参数的解析时在 ‘../lib/server/parser.js'里面的,文件一开始就指定使用了 nodejs 提供的专门对参数进行解析的 argparse 模块的 ArgumentPaser 类,具体这个类时怎么用的大家自己 google 就好了:
var ap = require('argparse').ArgumentParser
然后该 javascript 脚本就会实例化这个 ArgumentParser 来启动对参数的解析:
// Setup all the command line argument parsing
module.exports = function () {
var parser = new ap({
version: pkgObj.version,
addHelp: true,
description: 'A webdriver-compatible server for use with native and hybrid iOS and Android applications.'
});

_.each(args, function (arg) {
parser.addArgument(arg[0], arg[1]);
});

parser.rawArgs = args;

return parser;
};
ArgumentPaser 会对已经定义好的每一个 args 进行分析,如果有提供对应参数设置的就进行设置,没有的话就会提供默认值,这里我们提几个比较重要的参数作为例子:
var args = [
...
[['-a', '--address'], {
defaultValue: '0.0.0.0'
, required: false
, example: "0.0.0.0"
, help: 'IP Address to listen on'
}],
...
[['-p', '--port'], {
defaultValue: 4723
, required: false
, type: 'int'
, example: "4723"
, help: 'port to listen on'
}],
...
[['-bp', '--bootstrap-port'], {
defaultValue: 4724
, dest: 'bootstrapPort'
, required: false
, type: 'int'
, example: "4724"
, help: '(Android-only) port to use on device to talk to Appium'
}],
...
];
address :指定 http 服务器监听的 ip 地址,没有指定的话默认就监听本机
port :指定 http 服务器监听的端口,没有指定的话默认监听 4723 端口
bootstrap-port :指定要连接上安卓目标机器端的 socket 监听端口,默认 4724

  1. 创建 Express HTTP 服务器

Appium 支持两种方式启动,一种是在提供--shell 的情况下提供交互式编辑器的启动方式,这个就好比你直接在命令行输入 node,然后弹出命令行交互输入界面让你一行行的输入调试运行;另外一种就是我们正常的启动方式而不需要用户的交互,这个也就是我们今天关注的重点:
if (process.argv[2] && process.argv[2].trim() === "--shell") {
startRepl();
} else {
appium.run(args, function () { /* console.log('Rock and roll.'.grey); */ });
}
这里 appium 这个变量是从其他地方导入了,我们回到脚本较前位置:
var args = parser().parseArgs();
logFactory.init(args);

var appium = require('../lib/server/main.js');
可以看到,这个脚本首先会调用 parser 的模块去分析用户输入的参数然后保存起来(至于怎么解析的就不去看了,无非是读取每个参数然后保存起来而已,大家看下本人前面分析的其他源码是怎么获得启动参数的就清楚了),然后往下我们就可以看到 appium 这个变量是从'../lib/server/main.js'这个脚本导进来的,所以我们就需要去到这个脚本,浏览到脚本最下面的一行:
module.exports.run = main;
它是把 main 这个方法以 run 的名字导出给其他模块使用了,所以回到了最上面的:
appium.run(args, function () { /* console.log('Rock and roll.'.grey); */ });

就相当于调用了'main.js'的:
main(args, function () { /* console.log('Rock and roll.'.grey); */ });
我们往下看 main 这个方法,首先它会做一些基本的参数检查,然后初始化了一个 express 实例(Express 是目前最流行的基于 Node.js 的 Web 开发框架,提供各种模块,可以快速地搭建一个具有完整功能的网站,强烈建议不清楚其使用的童鞋先去看下牛人阮一峰的《Express 框架》),然后如平常一样创建一个 http 服务器:
var main = function (args, readyCb, doneCb) {
...
var rest = express()
, server = http.createServer(rest);
...
}
只是这个 http 服务器跟普通的服务器唯一的差别是 createServer 方法的参数,从一个回调函数变成了一个 Epress 对象的实例。它使用了 express 框架对 http 模块进行再包装的,这样它就可以很方便的使用 express 的功能和方法来快速建立 http 服务,比如:
通过 express 的 get,post 等快速设置路由。用于指定不同的访问路径所对应的回调函数,这叫做 “路由”(routing),这个也是为什么说 express 是符合 RestFul 风格的框架的原因之一了
使用 express 的 use 方法来设置中间件等。至于什么是中间件,简单说,中间件(middleware)就是处理 HTTP 请求的函数,用来完成各种特定的任务,比如检查用户是否登录、分析数据、以及其他在需要最终将数据发送给用户之前完成的任务。它最大的特点就是,一个中间件处理完,再传递给下一个中间件。
比如上面创建 http 服务器后所做的动作就是设置一堆中间件来完成特定的任务来处理 http 请求的:
var main = function (args, readyCb, doneCb) {
...
rest.use(domainMiddleware());
rest.use(morgan(function (tokens, req, res) {
// morgan output is redirected straight to winston
logger.info(requestEndLoggingFormat(tokens, req, res),
(res.jsonResp || '').grey);
}));
rest.use(favicon(path.join(dirname, 'static/favicon.ico')));
rest.use(express.static(path.join(
dirname, 'static')));
rest.use(allowCrossDomain);
rest.use(parserWrap);
rest.use(bodyParser.urlencoded({extended: true}));
// 8/18/14: body-parser requires that we supply the limit field to ensure the server can
// handle requests large enough for Appium's use cases. Neither Node nor HTTP spec defines a max
// request size, so any hard-coded request-size limit is arbitrary. Units are in bytes (ie "gb" == "GB",
// not "Gb"). Using 1GB because..., well because it's arbitrary and 1GB is sufficiently large for 99.99%
// of testing scenarios while still providing an upperbounds to reduce the odds of squirrelliness.
rest.use(bodyParser.json({limit: '1gb'}));
...
}
我们这里以第一个中间件为例子,看看它是怎么通过 domain 这个模块来处理异常的(注意 notejs 是出名的单线程,非阻塞的框架,正常的 try,catch 是抓获不了任何异常处理的,因为相应的代码不会等待如 i/o 操作等结果就立刻返回的,所以 nodejs 后来引入了 domain 这个模块来专门处理这种事情。其实我认为原理还是回调,把 http 过来的 nodejs 提供的 request,和 response 参数作为回调函数的参数提供给回调函数,然后一旦相应事件发生了就出发回调然后操作这两个参数进行返回):
module.exports.domainMiddleware = function () {
return function (req, res, next) {
var reqDomain = domain.create();
reqDomain.add(req);
reqDomain.add(res);
res.on('close', function () {
setTimeout(function () {
reqDomain.dispose();
}, 5000);
});
reqDomain.on('error', function (err) {
logger.error('Unhandled error:', err.stack, getRequestContext(req));
});
reqDomain.run(next);
};
};
大家可以看到这个回调中间件(函数):
先创建一个 domain
然后把 http 的 request 和 response 增加到这个 domain 里面
然后鉴定相应的事件发生,比如发生 error 的时候就打印相应的日记
然后调用下一个中间件来进行下一个任务处理
其他的中间件这里我就不花时间一一去分析了,大家各自跟踪下或者 google 应该就清楚是用来做什么事情的了,因为我自己就是这么干的。
main 函数在为 http 服务器建立好中间件后,下一步就是去创建一个 appium 服务器,注意这里 appium 服务器和 http 服务器是不一样的,http 服务器是用来监听 appium 客户端,也就是 selenium,我们的脚本发送过来的 http 的 rest 请求的;appium 服务器除了拥有着这个 http 服务器与客户端通信之外,还包含其他如和目标设备端的 bootstrap 通信等功能。
var main = function (args, readyCb, doneCb) {
...
// Instantiate the appium instance
var appiumServer = appium(args);
// Hook up REST http interface
appiumServer.attachTo(rest);
...
}
这里会去调用 appium 构造函数实例化一个 appium 服务器,然后把刚才创建的 express 对象 rest 给传到该服务器实例保存起来。那么这里这个 appium 类又是从哪里来的呢?我们返回到 main.js 的前面:
var http = require('http')
, express = require('express')
...
, appium = require('../appium.js')
可以看到它是从上层目录的 appium.js 导出来的,我们进去看看它的构造函数:
var Appium = function (args) {
this.args = _.clone(args);
this.args.callbackAddress = this.args.callbackAddress || this.args.address;
this.args.callbackPort = this.args.callbackPort || this.args.port;
// we need to keep an unmodified copy of the args so that we can restore
// any server arguments between sessions to their default values
// (otherwise they might be overridden by session-level caps)
this.serverArgs = _.clone(this.args);
this.rest = null;
this.webSocket = null;
this.deviceType = null;
this.device = null;
this.sessionId = null;
this.desiredCapabilities = {};
this.oldDesiredCapabilities = {};
this.session = null;
this.preLaunched = false;
this.sessionOverride = this.args.sessionOverride;
this.resetting = false;
this.defCommandTimeoutMs = this.args.defaultCommandTimeout * 1000;
this.commandTimeoutMs = this.defCommandTimeoutMs;
this.commandTimeout = null;
};
可以看到初始化的时候 this.rest 这个成员变量是设置成 null 的,所以刚提到的 main 中的最后一步就是调用这个 appium.js 中的 attachTo 方法把 express 实例 rest 给设置到 appium 服务器对象里面的:
Appium.prototype.attachTo = function (rest) {
this.rest = rest;
};
实例化 appium 服务器后,下一步就是要设置好从 client 端过来的请求的数据路由了,这个下一篇文章讨论 Appium Server 如何跟 bootstrap 通信时会另外进行讨论,因为它涉及到如何将客户端的请求发送给 bootstrap 进行处理。
var main = function (args, readyCb, doneCb) {
...
routing(appiumServer);
...
}
设置好路由后,main 往后就会对服务器做一些基本配置,然后调用 helpers.js 的 startListening 方法来开启 http 服务器的监听工作,大家要注意到现在为止 http 服务器 server 时创建起来了,但是还没有真正开始监听接受连接和数据的的工作的:
var main = function (args, readyCb, doneCb) {
...
function (cb) {
startListening(server, args, parser, appiumVer, appiumRev, appiumServer, cb);
}
...
}
注意它传入的几个重要参数:
server:基于 express 实例创建的 http 服务器实例
args:参数
parser:参数解析器
appiumVer: 在 ‘'../../package.json'‘文件中指定的 appium 版本号
appiumRev:通过上面提及的进行服务器基本配置时解析出来的版本修正号
appiumServer: 刚才创建的 appium 服务器实例,里面包含了一个 express 实例,这个实例和第一个参数 server 用来创建 http 服务器的 express 实例时一样的

  1. 启动 http 服务器监听

到了这里,整个基于 Express 的 http 服务器已经准备妥当,只差一个 go 命令了,这个 go 命令就是我们这里的启动监听方法:
module.exports.startListening = function (server, args, parser, appiumVer, appiumRev, appiumServer, cb) {
var alreadyReturned = false;
server.listen(args.port, args.address, function () {
var welcome = "Welcome to Appium v" + appiumVer;
if (appiumRev) {
welcome += " (REV " + appiumRev + ")";
}
logger.info(welcome);
var logMessage = "Appium REST http interface listener started on " +
args.address + ":" + args.port;
logger.info(logMessage);
startAlertSocket(server, appiumServer);
if (args.nodeconfig !== null) {
gridRegister.registerNode(args.nodeconfig, args.address, args.port);
}
var showArgs = getNonDefaultArgs(parser, args);
if (.size(showArgs)) {
logger.debug("Non-default server args: " + JSON.stringify(showArgs));
}
var deprecatedArgs = getDeprecatedArgs(parser, args);
if (
.size(deprecatedArgs)) {
logger.warn("Deprecated server args: " + JSON.stringify(deprecatedArgs));
}

logger.info('Console LogLevel: ' + logger.transports.console.level);

if (logger.transports.file) {
logger.info('File LogLevel: ' + logger.transports.file.level);
}
});
这个方法看上去很长,其实很多都是传给监听方法的回调函数的后期参数检查和信息打印以及错误处理,关键的就是最前面的启动 http 监听的方法:
server.listen(args.port, args.address, function () {
...
这里的 server 就是上面提及的基于 express 框架搭建的 Http Server 实例,传入的参数:
args.port :就是第一节提起的 http 服务器的监听端口,默认 4723
args.adress :就是第一节提及的 http 服务器监听地址,默认本地
function :一系列回调函数来进行错误处理等

  1. 小结

这篇文章主要描述了 appium server 是如何创建一个基于 express 框架的 http 服务器,然后启动相应的监听方法来获得从 appium client 端发送过来的数据,至于获取到数据后如何与目标安卓设备的 bootstrap 进行通信,敬请大家期待本人的下一篇文章。本人更多的文章请参考我的博客:http://blog.csdn.net/zhubaitian

作者:天地会珠海分舵
http://techgogogo.com
http://blog.csdn.net/zhubaitian

共收到 1 条回复 时间 点赞
恒温 Appium 入门到原理合集 中提及了此贴 03月16日 08:33

这种文章都没啥关注,不应该呀😏

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册