Appium简介

Appium 是一个开源工具,用于自动化 iOS 手机 、 Android 手机和 Windows 桌面平台上的原生、移动 Web 和混合应用。“原生应用”指那些用 iOS 、 Android 或者 Windows SDK 编写的应用。“移动 web 应用”是用移动端浏览器访问的应用(Appium 支持 iOS 上的 Safari 、Chrome 和 Android 上的内置浏览器)。“混合应用”带有一个 “webview” 的包装器——用来和 Web 内容交互的原生控件。类似 Phonegap 的项目,让用 Web 技术开发然后打包进原生包装器创建一个混合应用变得容易了。

重要的是,Appium 是跨平台的:它允许你用同样的 API 对多平台写测试,做到在 iOS 、Android 和 Windows 测试套件之间复用代码。

了解 Appium “支持”这些平台意味着什么、有哪些自动化方式的详细信息,请参见 Appium 支持的平台

  1. Appium官网:http://appium.io/
  2. Appium的Github仓库:https://github.com/appium/appium

Appium的原理和架构

图1:

以python语言为例,假设脚本(main.py)中只有“点击按钮btn1”这一个动作,对应的语句为:find_element_by_id(“btn1”).click()。那么appium的工作流程如下:

  1. 启动appium server,它会自动完成如下一些事:

    (1) appPackage=” ${aapt解析出来的被测应用包名}”,例如在地方棋牌大厅这个例子中,是package=”com.boyaa.engineqpsc”

    (2) appActivity=” ${ aapt解析出来的被测应用启动Activity}” ,例如在地方棋牌大厅这个例子中,是targetPackage =”com.boyaa.engineqpsc.Game”
    图2:

  2. 执行main.py, 把main.py交给appium python client解析执行。

  3. python client把动作包装成http请求,发送给appium http server。

  4. http server把“点击按钮1”请求解析成socket client和server之间规定的通信格式,并通过socket发送请求到手机。

  5. 运行在手机上的socket server,负责把“点击按钮1”请求解析为具体的手机本地api,并调用手机本地服务执行。

  6. 执行结果沿着上述通信路线层层返回。

其他语言的工作流程也基本一致,区别只在于appium client这里,各个语言的API略有区别。

由此可以看出,appium server最大的好处是,在具体的命令执行上封装了一层统一的基于http协议构建的请求格式,不同语言的客户端可以无差别地调用。

When all is said and done, Appium is just an HTTP server. It sits and waits for connections from a client, which then instructs Appium what kind of session to start and what kind of automation behaviors to enact once a session is started.This means that you never use Appium just by itself. You always have to use it with a client library of some kind (or, if you’re adventurous, cURL!).

Luckily, Appium speaks the same protocol as Selenium, called the WebDriver Protocol. You can do a lot of things with Appium just by using one of the standard Selenium clients. You may even have one of these on your system already. It’s enough to get started, especially if you’re using Appium for the purpose of testing web browsers on mobile platforms.

Appium can do things that Selenium can’t, though, just like mobile devices can do things that web browsers can’t. For that reason, we have a set of Appium clients in a variety of programming languages, that extend the regular old Selenium clients with additional functionality. You can see the list of clients and links to download instructions at the Appium clientslist.

Before moving forward, make sure you have a client downloaded in your favorite language and ready to go.

Appium的源码结构

从Appium的Github仓库将源码clone下来之后(本文采用的是2018.7.16日的最新master分支,将源码仓库用git clone https://github.com/appium/appium.git命令clone之后,在Appium本地仓库目录用git checkout fe1b58b09b27ee397de33fb740ca4a348316676e命令切换到本文采用的版本),在文件系统中,看到的目录结构如下图:

Appium的源码工程是一个典型的NodeJS工程(Appium is written in JavaScript, and run with the Node.js engine. Currently version 6+ is supported. ),可以采用Visual studio code打开(最新版的Appium工程就是在vs code下开发,从上图中的.vscode子目录即可看出),其中,lib子目录是Appium的源码目录。而里面的main.js是Appium的程序入口。用vs code打开Appium源码所在根目录即将整个工程导入并打开,然后在根目录下打开命令行,在命令行执行npm install安装工程依赖的所有NodeJS library)。

Appium的日志分析

我们从main.js入手,一步步分析Appium的内部逻辑和日志打印。main.js的main入口函数代码如下:

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
99     async function main (args = null) {
100 let parser = getParser();
101 let throwInsteadOfExit = false;
102 if (args) {
103 // a containing package passed in their own args, let's fill them out
104 // with defaults
105 args = Object.assign({}, getDefaultArgs(), args);
106
107 // if we have a containing package instead of running as a CLI process,
108 // that package might not appreciate us calling 'process.exit' willy-
109 // nilly, so give it the option to have us throw instead of exit
110 if (args.throwInsteadOfExit) {
111 throwInsteadOfExit = true;
112 // but remove it since it's not a real server arg per se
113 delete args.throwInsteadOfExit;
114 }
115 } else {
116 // otherwise parse from CLI
117 args = parser.parseArgs();
118 }
119 initHeapdump(args);
120 await logsinkInit(args);
121 await preflightChecks(parser, args, throwInsteadOfExit);
122 await logStartupInfo(parser, args);
123 let appiumDriver = new AppiumDriver(args);
124 let router = routeConfiguringFunction(appiumDriver);
125 let server = await baseServer(router, args.port, args.address);
126 appiumDriver.server = server;
127 try {
128 // TODO prelaunch if args.launch is set
129 // TODO: startAlertSocket(server, appiumServer);
130
131 // configure as node on grid, if necessary
132 if (args.nodeconfig !== null) {
133 await registerNode(args.nodeconfig, args.address, args.port);
134 }
135 } catch (err) {
136 await server.close();
137 throw err;
138 }
139
140 process.once('SIGINT', async function () {
141 logger.info(`Received SIGINT - shutting down`);
142 await server.close();
143 });
144
145 process.once('SIGTERM', async function () {
146 logger.info(`Received SIGTERM - shutting down`);
147 await server.close();
148 });
149
150 logServerPort(args.address, args.port);
151
152 return server;
153 }
154
155 if (require.main === module) {
156 asyncify(main);
157 }

  • 100-118行,解析传入的参数args,这里一般是从命令行传入(比如加上-p用于指定Appium server的监听端口),args可以指定的格式和内容请参见server-args
  • 119行,initHeapdump函数,会根据启动参数中是否指明启动堆转储(应该是指将javascript的堆内存dump到磁盘文件,便于分析异常和内存泄露等问题),这里不做深入探讨;
  • 120行,logsinkInit函数,对log日志进行处理,比如添加颜色显示,或者添加前缀等;
  • 121行,preflightChecks函数对NodeJS的版本和环境等进行一些检查;
  • 122行,logStartupInfo函数打印Appium启动的信息,包括Appium的版本等(如图2所示)

    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
    61    async function logStartupInfo (parser, args) {
    62 let welcome = `Welcome to Appium v${APPIUM_VER}`;
    63 let appiumRev = await getGitRev();
    64 if (appiumRev) {
    65 welcome += ` (REV ${appiumRev})`;
    66 }
    67 logger.info(welcome);
    68
    69 let showArgs = getNonDefaultArgs(parser, args);
    70 if (_.size(showArgs)) {
    71 logNonDefaultArgsWarning(showArgs);
    72 }
    73 let deprecatedArgs = getDeprecatedArgs(parser, args);
    74 if (_.size(deprecatedArgs)) {
    75 logDeprecationWarning(deprecatedArgs);
    76 }
    77 if (!_.isEmpty(args.defaultCapabilities)) {
    78 logDefaultCapabilitiesWarning(args.defaultCapabilities);
    79 }
    80 // TODO: bring back loglevel reporting below once logger is flushed out
    81 //logger.info('Console LogLevel: ' + logger.transports.console.level);
    82 //if (logger.transports.file) {
    83 //logger.info('File LogLevel: ' + logger.transports.file.level);
    84 //}
    85 }
  • 123-126行,这是main函数的关键,这里真正创建了HTTP server,并接受Appium client发送的HTTP请求。123行创建的AppiumDriver定义在Appium源码的appium.js文件中,在其内部包含了createSession、 executeCommand等很多关键方法。createSession在Appium server接受到Appium client发送的”host:port/wd/hub/session”请求时被调用(相关映射可在$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/mjsonwp/routes.js中定义,如下代码片段所示),$APPIUM_ROOT_DIR表示Appium源码根目录,下文同义:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    4     // define the routes, mapping of HTTP methods to particular driver commands,
    5 // and any parameters that are expected in a request
    6 // parameters can be `required` or `optional`
    7 const METHOD_MAP = {
    8 '/wd/hub/status': {
    9 GET: {command: 'getStatus'}
    10 },
    11 '/wd/hub/session': {
    12 POST: {command: 'createSession', payloadParams: {
    13 validate: (jsonObj) => (!jsonObj.capabilities && !jsonObj.desiredCapabilities) && 'we require one of "desiredCapabilities" or "capabilities" object',
    14 optional: ['desiredCapabilities', 'requiredCapabilities', 'capabilities']}}
    15 },
    16 .
    17 .
    18 .
    474 };

124行的routeConfiguringFunction函数(从函数的名称可知,表示路由配置),定义在$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/mjsonwp/mjsonwp.js,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
193    function routeConfiguringFunction (driver) {
194 if (!driver.sessionExists) {
195 throw new Error('Drivers used with MJSONWP must implement `sessionExists`');
196 }
197
198 if (!(driver.executeCommand || driver.execute)) {
199 throw new Error('Drivers used with MJSONWP must implement `executeCommand` or `execute`');
200 }
201
202 // return a function which will add all the routes to the driver
203 return function (app) {
204 for (let [path, methods] of _.toPairs(METHOD_MAP)) {
205 for (let [method, spec] of _.toPairs(methods)) {
206 // set up the express route handler
207 buildHandler(app, method, path, spec, driver, isSessionCommand(spec.command));
208 }
209 }
210 };
211 }

从中可得知,routeConfiguringFunction函数的功能和其名字完全对应。即根据$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/mjsonwp/routes.js定义的METHOD_MAP映射,将Appium client发送的HTTP请求(请求的url的path代表了一条路由route)映射到该函数传入的driver参数的相应命令(函数)去执行。这里传入的driver就是123行定义的AppiumDriver,从而也印证了上文提到的,Appium client发送的”host:port/wd/hub/session”请求时,最终是调用了AppiumDriver的createSession方法(在appium.js的237-330行,可重点关注259和260两行的详细逻辑)。
回到main.js的125行let server = await baseServer(router, args.port, args.address),该行是真正创建Appium运行的HTTP server。跟踪代码可知,baseServer真正定义在$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/express/server.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
15    async function server (configureRoutes, port, hostname = null) {
16 // create the actual http server
17 let app = express();
18 let httpServer = http.createServer(app);
19
20 // http.Server.close() only stops new connections, but we need to wait until
21 // all connections are closed and the `close` event is emitted
22 let close = httpServer.close.bind(httpServer);
23 httpServer.close = async () => {
24 return await new Promise((resolve, reject) => {
25 httpServer.on('close', resolve);
26 close((err) => {
27 if (err) reject(err);
28 });
29 });
30 };
31
32 return await new Promise((resolve, reject) => {
33 httpServer.on('error', (err) => {
34 if (err.code === 'EADDRNOTAVAIL') {
35 log.error('Could not start REST http interface listener. ' +
36 'Requested address is not available.');
37 } else {
38 log.error('Could not start REST http interface listener. The requested ' +
39 'port may already be in use. Please make sure there is no ' +
40 'other instance of this server running already.');
41 }
42 reject(err);
43 });
44 httpServer.on('connection', (socket) => {
45 socket.setTimeout(600 * 1000); // 10 minute timeout
46 });
47 configureServer(app, configureRoutes);
48
49 let serverArgs = [port];
50 if (hostname) {
51 // If the hostname is omitted, the server will accept
52 // connections on any IP address
53 serverArgs.push(hostname);
54 }
55 httpServer.listen(...serverArgs, (err) => {
56 if (err) {
57 reject(err);
58 }
59 resolve(httpServer);
60 });
61 });
62 }

可重点关注上面的47行即configureServer(app, configureRoutes);以及configureServer函数(比如该函数的88行app.use(startLogFormatter);,这行将会在收到Appium client的HTTP请求的时候,打印HTTP的方向”–>”以及HTTP的请求方法比如POST等。startLogFormatter以及对应的endLogFormatter定义在$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/express/express-logging.js中)的详细逻辑。最终客户端发送过来的HTTP请求(path,路径),经过configureRoutes的路由(这里configureRoutes参数就是main.js里124行routeConfiguringFunction函数返回的router),找到AppiumDriver的对应方法处理。

  • 150行,logServerPort函数打印Appium server监听的IP地址和端口(如图2所示)。
    1
    2
    3
    4
    5
    87    function logServerPort (address, port) {
    88 let logMessage = `Appium REST http interface listener started on ` +
    89 `${address}:${port}`;
    90 logger.info(logMessage);
    91 }

关于打印日志用到的logger,其实是定义在logger.js之中(Appium源码lib子目录下),如下:

1
2
3
4
5
6
1    import { logger } from 'appium-support';
2
3
4 let log = logger.getLogger('Appium');
5
6 export default log;

进一步追踪,那么是来自Appium工程依赖的一个NodeJS library,即appium-support(查看Appium源码根目录下的package.json文件,可以在依赖配置里面找到对应的项,如下44行所示)。

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
36    "dependencies": {
37 "appium-android-driver": "3.x",
38 "appium-base-driver": "^3.0.0",
39 "appium-espresso-driver": "^1.0.0-beta.3",
40 "appium-fake-driver": "^0.x",
41 "appium-ios-driver": "2.x",
42 "appium-mac-driver": "1.x",
43 "appium-selendroid-driver": "1.x",
44 "appium-support": "2.x",
45 "appium-tizen-driver": "^1.0.0-beta",
46 "appium-uiautomator2-driver": "1.x",
47 "appium-windows-driver": "1.x",
48 "appium-xcuitest-driver": "2.x",
49 "appium-youiengine-driver": "1.x",
50 "argparse": "^1.0.10",
51 "async-lock": "^1.0.0",
52 "asyncbox": "2.x",
53 "babel-runtime": "=5.8.24",
54 "bluebird": "3.x",
55 "continuation-local-storage": "3.x",
56 "dateformat": "^3.0.3",
57 "find-root": "^1.1.0",
58 "lodash": "=4.17.9",
59 "npmlog": "4.x",
60 "request": "^2.81.0",
61 "request-promise": "4.x",
62 "semver": "^5.5.0",
63 "source-map-support": "0.x",
64 "teen_process": "1.x",
65 "winston": "2.x"
66 },

而appium-support库,可以通过在Appium的源码根目录下执行npm install命令(要使用npm命令,前提是先安装NodeJS)来安装(npm install执行之后,会在Appium的源码根目录下生成node_modules子目录,node_modules目录中包含了Appium源码工程依赖的NodeJS library),或者直接在其Github上的源码仓库https://github.com/appium/appium-support下载后查看。其打印相关的功能实际在$APPIUM_ROOT_DIR/node_modules/appium-support/lib/logging.js中定义($APPIUM_ROOT_DIR代表Appium的源码根目录)。该文件中getLogger (prefix = null)方法如下:

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
42    function getLogger (prefix = null) {
43 let [logger, usingGlobalLog] = _getLogger();
44
45 // wrap the logger so that we can catch and modify any logging
46 let wrappedLogger = {unwrap: () => logger};
47
48 // allow access to the level of the underlying logger
49 Object.defineProperty(wrappedLogger, 'level', {
50 get: () => { return logger.level; },
51 set: (newValue) => { logger.level = newValue; },
52 enumerable: true,
53 configurable: true
54 });
55 // add all the levels from `npmlog`, and map to the underlying logger
56 for (let level of NPM_LEVELS) {
57 wrappedLogger[level] = logger[level].bind(logger, prefix);
58 }
59 // add method to log an error, and throw it, for convenience
60 wrappedLogger.errorAndThrow = function (err) {
61 // make sure we have an `Error` object. Wrap if necessary
62 if (!(err instanceof Error)) {
63 err = new Error(err);
64 }
65 // log and throw
66 this.error(err);
67 throw err;
68 };
69 if (!usingGlobalLog) {
70 // if we're not using a global log specified from some top-level package,
71 // set the log level to a default of verbose. Otherwise, let the top-level
72 // package set the log level
73 wrappedLogger.level = 'verbose';
74 }
75 wrappedLogger.levels = NPM_LEVELS;
76 return wrappedLogger;
77 }

从而得知,getLogger方法传入的其实就是一个前缀,Appium在打印每行日志时,会在消息体前面加上这个前缀。

而在Appium源码lib子目录下的logger.js中,第4行logger.getLogger('Appium')其实是给Appium打印的每行日志加上[Appium]的前缀,这也就是我们在Appium日志中看到的(如本文图2所示)。那其他的前缀比如[HTTP]、[ADB]等又是在哪里添加的呢?类似的分析方法,可以在Appium源码工程依赖的对应的NodeJS library中查看,基本每个library都自定义了一个logger.js,与$APPIUM_ROOT_DIR/lib/logger.js类似,都是最终引用到appium-support库的logging.js(如上文所示)来实现,但是赋予了不同的前缀。比如,[HTTP]前缀表示Appium server接收或者返回HTTP请求给Appium client。前文提到,Appium server本身就是一个HTTP server,其底层采用express框架来简化搭建HTTP server(express is a fast, unopinionated, minimalist web framework for Node.js。STF平台的HTTP server其实也是采用express框架来搭建的)。$APPIUM_ROOT_DIR/node_modules/appium-base-driver/lib/express/logger.js定义如下:

1
2
3
4
1    import { logger } from 'appium-support';
2
3 const log = logger.getLogger('HTTP');
4 export default log;

可以看到就是这里添加了[HTTP]前缀。其他前缀包括日志级别同理可分析得之。

参考目录

  1. http://mtc.baidu.com/about#appium
  2. https://github.com/appium/appium/blob/master/docs/cn/about-appium/intro.md
  3. https://blog.csdn.net/itfootball/article/details/45395901
  4. https://blog.csdn.net/jffhy2017/article/details/69372064