QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

knysa:异步等待风格 PhantomJS 脚本编程

  • 2016-10-10
  • 本文字数:6275 字

    阅读完需:约 21 分钟

要点

  • knysa 允许异步等待风格的 PhantomJS 异步编程;
  • knysa 减少对柯里化(curry)的需求;
  • knysa 支持 try/catch/finally 流程块;
  • knysa 对浏览器的 AJAX 调用有更好的支持;
  • knysa 试程序流程更加自然;

PhantomJS是提供 JavaScript API 的可编程无头浏览器(无图形界面)。它非常适合页面自动化和测试。其 JavaScript API 非常优秀,提供了许多高级功能,但同时也陷入了 JavaScript 常常遇到的“回调地狱(callback hell)”,既深度嵌套的回调。

目前,已经有很多库和框架致力于解决这个问题。对于 PhantomJS 来说,CasperJS 是其中一个流行的解决方案,但是它仅仅减轻了问题,并没有解决问题。knysa 从另一方面优雅的解决了这个问题。与类似 CasperJS,knysa 允许开发者有顺序的编写步骤。不同于 CasperJS,knysa 不会添加大量的样板代码(如 casper.then() 等)。

更重要的是,knysa 允许开发者使用诸如 if/else/while/break/try/catch/finally 等代码结构,更加自然的控制程序流程。

让我们使用一个示例来演示嵌套问题和 knysa 的理念。以下示例是一段 CasperJS 脚本,其流程是在 Google 上搜索关键字“CasperJS”,然后检查搜索结果页面上的每个链接到的页面是否包含关键字“CasperJS”:

  • (第 9 行)打开 Google 网页,等待页面加载完毕;
  • (第 11 行)网页加载完毕后,填充搜索框并提交,然后等待响应;
  • (第 13 行)处理响应:
    • (第 16、17 行)访问响应中的每个链接,并且等待页面加载;
    • (第 18-23 行)当链接的页面加载完毕后,检查关键字“CasperJS”是否存在;

上面的描述非常简单直接,但是 CasperJS 的嵌套语法使得代码看上去比较复杂。

复制代码
1 var links = [];
2 var casper = require('casper').create();
3 function getLinks() {
4 var links = document.querySelectorAll('h3.r a');
5 return Array.prototype.map.call(links, function(e) {
6 return e.getAttribute('href');
7 });
8 }
9 casper.start('http://google.com/', function() {
10 // 通过 google 表单搜索“CasperJS”关键字
11 this.fill('form[action="/search"]', { q: 'CasperJS' }, true);
12 });
13 casper.then(function() {
14 // 聚合“CasperJS”关键字搜索结果
15 links = this.evaluate(getLinks);
16 for (var i = 0; i < links.length; i++) {
17 casper.thenOpen(links[i]);
18 casper.then(function() {
19 var isFound = this.evaluate(function() {
20 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0;
21 });
22 console.log('CasperJS is found on ' + links[i] + ':' + isFound);
23 });
24 }
25 });
26 casper.run();

我们可以看到,第 18 行的 casper.then() 嵌套在 13 行的另外一个 casper.then() 函数中。这样的嵌套模糊了程序逻辑,使得程序流程混乱。脚本执行过程中,执行流程不是仅仅向前的,程序流程有 3 个混杂的阶段:

  1. 阶段 1(第 9、13、26 行):通过使用 casper.start()(第 9 行)和 casper.then()(第 13 行)创建执行步骤(匿名函数)。这些步骤最后通过执行 capser.run()(第 26 行)开始执行。
  2. 阶段 2(第 11、15、16、17、18 行):随着步骤的执行,步骤中的代码(匿名行数)被执行。
  3. 阶段 3(第 19、20、21、22 行):在原步骤列表中增加更多步骤,并且执行。

于是每个嵌套级别增加了一个执行阶段。

由于这些混杂的阶段,脚本中的每行代码和脚本执行顺序不再匹配。例如,13 行在第 11 行前执行。这对于程序来说难以阅读和定位问题。另一个问题是难以增加“if/else”的判断逻辑或者处理任何异常。第三个问题是:第 22 行的links[i]总是会打印“undefined”!

这是为什么呢?

因为在阶段 3 的第 22 行之前时,变量“i”已经在阶段 2 中被修改成了links.length。为了修复这个问题,我们必须采取柯里化方式(10a/18b 和 22a 行)。这里我们使用变量“link”来保存 links[i] 的值(第 18a 行),然后执行一个匿名函数来返回另一个匿名函数(第 18b 行):

复制代码
18 casper.then(function() {
18a var link = links[i];
18b return function() {
19 var isFound = this.evaluate(function() {
20 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0;
21 });
22 console.log('CasperJS is found on ' + link + ':' + isFound);
22a }
23 }());

我们可以看见,通过柯里化,“link”现在有了正确的值,但是柯里化增加了更多的嵌套代码。这太糟糕了,我们能够做的更好吗?

答案是肯定的。

事实上,通过 knysa,我们可以做的更好:我们可以完全去除代码中的嵌套和柯里化,脚本将会更加干净和可读,同时程序执行流程也会更加自然。

以下是实现相同功能的 knysa 脚本(注意我们引入了隐式变量“kflow”和“kflow”上的函数,同时还有一些“knysa_”开头的函数,我们将在后面进行介绍):

  • (第 9 行)打开 Google 网页并等待网页加载;
  • (第 10 行)在网页加载后,填充和提交搜索表单,并等待响应返回;
  • (第 13 行)处理响应:
    • (第 14 行)访问响应中的每个链接,并等待网页加载完毕;
    • (第 15-18 行)当链接对应的页面加载完毕后,检查关键字“CasperJS”是否存在;

嵌套代码和柯里化都消失了!现在,代码的执行顺序和脚本中的代码行想对应了。这个顺序也和上面描述的流程相同。整个代码流程中只有一个阶段,代码变得可读,问题定位也更方便。

复制代码
1 var links = [];
2 var i, num, isFound;
3 function getLinks() {
4 var links = document.querySelectorAll('h3.r a');
5 return Array.prototype.map.call(links, function(e) {
6 return e.getAttribute('href');
7 });
8 }
9 kflow.knysa_open('http://google.com/');
10 kflow.knysa_fill('form[action="/search"]', { q: 'CasperJS' });
11 links = kflow.evaluate(getLinks);
12 i = -1;
13 while (++i < links.length) {
14 kflow.knysa_open(links[i]);
15 isFound = kflow.evaluate(function() {
16 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0;
17 });
18 console.log('CasperJS is found on ' + links[i] + ':' + isFound);
19 }
20 phantom.exit();

这是什么魔法?魔法位于每个以“knysa_”为前缀的函数(位于第 9、10 和 14 行),这些函数都是异步(async)执行,knysa 等待(await)当前异步调用结束,再继续执行下一行。

knysa 将每个脚本作为流程,并且在执行时赋予其一个 ID。流程对象可以通过隐式变量“kflow”暴露出来。流程 ID 可以通过 kflow.getId() 获取。

kflow 提供了一些异步等待风格的浏览器导航行数,如 knysa_open、knysa_fill、knysa_click 和 knysa_evaluate。对于新的网页,knysa_open、knysa_fill 和 knysa_click 行数会等待他们加载结束:

  1. knysa_open(url):打开一个网页;
  2. knysa_click(selector):触发点击操作;
  3. knysa_fill(formSelector, values):填充和提交表单

knysa_evaluate(func, kflowId[, arg0, arg1, …]):和 PhantomJS 的 page.evaluate() 函数相同,可以在浏览器端(沙盒中)执行包括 AJAX 调用在内的任意 JavaScript。相比于 PhantomJS 的 page.evaluate() 函数,knysa_evaluate 提升了对 AJAX 的支持。它挂起脚本执行。为了恢复执行,“回调函数”内部的代码(通常是 AJAX 调用的成功 / 失败回调)必须调用“window.callPhantom(data)”,其中“data.kflowId”需设置成“kflowId”。这里有一个来自 opl.kns 的示例:AJAX 请求用于续借图书,脚本执行会在续借响应请求收到后恢复:

复制代码
oneRenewResult = kflow.knysa_evaluate(renew, kflow.getId(), ...);

其中沙盒中的函数“renew”有以下几行:

复制代码
1 $.ajax({
2 dataType: 'json',
3 inline_messaging: 1,
4 url: form.attr("action"),
5 data: form.serialize(),
6 success: function(e) {
7 console.log("success: " + JSON.stringify(e));
8 window.callPhantom({kflowId : kflowId, status: 'success', data: e});
9 },
10 failure: function(e) {
11 console.log("failure: " + JSON.stringify(e));
12 window.callPhantom({kflowId : kflowId, status: 'failure', data: e});
13 }
14 });

脚本会再 AJAX 调用结束之后恢复。根据 AJAX 调用的结果,oneRenewResult 将被设置为不同的值:

  • 当 AJAX 调用成功,第 8 行恢复执行并将 oneRenewResult 设置为:{kflowId : kflowId, status: ‘success’, data: e}
  • 当 AJAX 调用失败,第 12 行恢复执行,并将 oneRenewResult 设置为:{kflowId : kflowId, status: ‘failure’, data: e}

注意:传入 window.callPhantom() 函数的所有数据都将作为 knysa_evaluate() 的返回值。

kflow.sleep(milliseconds) 是另一个异步等待函数,但是它被 knysa 特殊处理。

kflow 同时也提供一些常规(非异步等待)函数。这些函数直接来自 CasperJS API:

  • open(url)
  • click(selector)
  • fill(selector)
  • getHTML(selector, outer)
  • exists(selector)
  • download(url, path, method, data)
  • getElementAttr(selector, attrName)
  • render(path)
  • evaluate(func[, arg0, arg1…])

实现自己的异步等待风格函数

为了实现这个目的,只需要将函数名字加上“knysa_”前缀。这将告知 knysa 这是一个异步等待风格函数。当这样的函数调用时,脚本执行将会挂起。但是自己实现的异步等待风格函数需要通过调用 kflow.resume(data) 函数自行恢复脚本执行。当执行恢复时,传给 kflow.resume 函数的“data”参数将会变成异步等待函数的返回值。这里是一个来自 resume.kns 的示例:它首先休眠 1 秒,然后将输入值“num”乘以 100 并返回:

复制代码
1 function knysa_f1(kflow, num) {
2 setTimeout(function() {
3 kflow.resume(num * 100);
4 }, 1000);
5 // return num + 10;
6 }

该函数的返回值是传递给 kflow.resume() 函数的参数,例如 num * 100。

重要提示 1:在类似异步等待函数中,常规返回值将被忽略。例如,即使第 5 行没有注释,“return num + 10”语句的结果也会被简单的丢弃。

重要提示 2:异步等待风格函数的调用必须是一个单独的语句。可以是:

复制代码
knysa_my_func(...);
或者
ret = knysa_my_func(...);

也可以作为对象函数使用:

复制代码
myObj.knysa_my_func(...);
或者
ret = myObj.knysa_my_func(...);

下面的调用方式无法支持:

复制代码
if (knysa_my_func(...)) ...
可以改成这样:
val = knysa_my_func(...);
if (val) ...
var1 = abc * knysa_my_func(...)
可以改成这样:
val = knysa_my_func(...);
var1 = abc * val;

这里是调用前面定义的 knysa_f1 函数的示例,其返回值会被赋值到一个变量:

复制代码
ret = knysa_f1(5);

当这行代码执行时,ret 将在 1 秒延迟后被设置为 500。

异常处理

knysa 的异常处理机制出奇的简单:老式的 try/catch/finally 结构。这样的基础设施在 CasperJS 中是缺失的。示例: try.kns

catch”示例:以下代码在发生任何异常时渲染一张调试图片。

复制代码
var err; // 变量必须在开头定义
...
try {
...
} catch (err) {
kflow.render(image_path);
console.log(err.stack);
}

“finally”示例:以下代码确保在发生异常时登出:

复制代码
// 填充并提交表单,登录网站
kflow.knysa_fill(...);
try {
...
} finally {
// 打开登出链接以登出
kflow.knysa_open(logout_link);
}

注意事项:

  1. “else if”语法不支持,请使用嵌套的“if/else”语句替代;
  2. “for”循环体不能有异步等待函数调用或者“break”语句,请使用“while”循环替代;
  3. 所有变量必须在开头定义,包括 catch(err) 语句中的“err”变量;
  4. 隐式变量“kflow”不能用于变量定义;

内部工作原理:

knysa 脚本在执行前首先会被转换成 JavaScript。转换后的脚本是很多步骤的流程,每个步骤一个函数。每个函数的名字被编码上流程控制信息:

  • 每个函数都被编号(为了决定执行顺序)。
  • _async”后缀表示脚本执行将会被挂起。脚本执行将会在恰当的条件满足后恢复:例如页面响应接收到,或者 AJAX 响应接受到等。每个异步等待语句被转换成类似的函数。
  • _while”后缀但中间不包含“endwhile”的函数名表示 while 循环的开始。
  • _while”后缀但中间包含“endwhile”的函数名表示 while 循环的结束。
  • 虽然没有说明,“if/else/try/catch/finally/break”语句的转化方式和“while”语句类似。

下面是之前示例中去 Google 搜索的 knysa 脚本转换后的 JavaScript 脚本:

复制代码
var knysa = require("./knysa.js");
function knycon_search_casperjs_10001() {
var links = [];
var i, num, isFound;
function getLinks() {
var links = document.querySelectorAll("h3.r a");
return Array.prototype.map.call(links, function(e) {
return e.getAttribute("href");
});
}
this.n50002_async = function(kflow) {
kflow.knysa_open("http://google.com/");
}
this.n50003_async = function(kflow) {
kflow.knysa_fill('form[action="/search"]', {
q: "CasperJS"
});
}
this.n50004 = function(kflow) {
links = kflow.evaluate(getLinks);
i = -1;
}
this.n50005_while = function(kflow) {
return ++i < links.length;
};
this.n50006_async = function(kflow) {
kflow.knysa_open(links[i]);
}
this.n50007 = function(kflow) {
isFound = kflow.evaluate(function() {
return document.querySelector("html").textContent.indexOf("CasperJS") >= 0;
});
console.log("CasperJS is found on " + links[i] + ":" + isFound);
}
this.n50008_endwhile_n50005_while = function() {};
this.n50009 = function(kflow) {
phantom.exit();
}
}
knysa.knysa_exec(new knycon_search_CasperJS_10001);

注意 1:以上转换后的 JavaScript 只是为了展示当前的实现细节。knysa 的实现可能改变。例如,将来的版本可能会使用 Promises。当然,当 PhantomJS 完全支持 ES6 的 generators 或者 ES7 中的 async/await,knysa 可能就不再需要。

注意 2:虽然 knysa 减少了通过使用回调来控制脚本执行顺序,knysa 本身使用了 PhantomJS 的回调机制,例如 page.onCallback() 和 page.onLoadFinished()。

实践时间

现在我们已经看见通过 kynsa 来操作 PhantomJS 是多么容易和自然,为什么不自己尝试呢? knysa 托管在 github。我们可以从示例开始。我(作者)也期待听到大家的反馈。由于 knysa 是新项目,还有很多提升空间,欢迎大家能够对项目做出贡献。贡献的方式有多种:

  1. 处理 ticket
  2. 提供更多的示例脚本,不论大小;
  3. 或者更好的是,共享可以帮助处理日常零活的 knysa 脚本,这样可以帮助其他人节省时间,提高工作效率;

致谢

  1. uglifyjs1 用于解析 knysa 脚本并生成响应 javascript;
  2. 许多“kflow”函数直接从 CasperJS 提取;

关于作者

Bo Zou是一个经验丰富的软件开发者。他对于许多 web 自动化工具都有经验,包括 Perl、HttpUnit、HtmlUnit、Watij 等。最近他一直专注于 PhantomJS 和 Android。

查看英文原文: https://www.infoq.com/articles/knysa-phantomjs-async-await

2016-10-10 18:092611

评论

发布
暂无评论
发现更多内容

「产品经理训练营」第五章作业

Sòrγy_じò ぴé

产品经理训练营

架构设计篇之微服务实战笔记(四)

小诚信驿站

架构师 刘晓成 小诚信驿站 28天写作 架构师成长笔记

读书笔记-MySQL索引

yunCrush

话题讨论 | 你”节后综合症“了吗?

程序员架构进阶

话题讨论 28天写作 2月春节不断更 话题王者 节后焦虑

快速搭建本土「Clubhouse」

anyRTC开发者

ios android WebRTC RTC 语音通话

IAR故障解决:由于找不到mfc140u.dll,无法继续执行代码

不脱发的程序猿

28天写作 二月春节不断更 IAR 软件故障 DLL库

趣谈哈希表优化:从规避 Hash 冲突到利⽤ Hash 冲突

百度Geek说

大前端 测试 哈希表 hash 研发工具

【LeetCode】爱生气的书店老板Java题解

Albert

算法 LeetCode 28天写作 2月春节不断更

区块链电子合同应用平台,区块链存证系统

13530558032

做一个很出色的程序员

四猿外

Java 程序员 面试 架构师 职场成长

一篇学会RSA JavaScript加密,涉及OpenSSL

梁龙先森

JavaScript 大前端 28天写作 2月春节不断更

深入理解 Web 协议(三):HTTP 2

vivo互联网技术

Web HTTP HTTP2.0

为了面试阿里巴巴、腾讯、字节跳动、京东、华为等大厂,我收集了8家大厂高频Java面试真题集锦(含答案)

Java架构之路

Java 程序员 架构 面试 编程语言

Protobuf源码解读之编解码

batman

protubuf zigzag varint

农产品区块链溯源平台,区块链溯源方案

13530558032

话题讨论 | 技术从入门到熟练是怎样的?

happlyfox

话题讨论 28天写作

从架构设计的演进来看,我们真的需要DDD

三石

DDD 软件架构 话题讨论

读懂框架设计的灵魂—Java反射机制

Java 编程 架构

60K*17薪的面试题是什么样的,需要具备什么技术?首发“Java面试考点大全”

Java架构之路

Java 程序员 架构 面试 编程语言

产品训练营第五周作业

产品经理训练营

LeetCode题解:322. 零钱兑换,动态规划,JavaScript,详细注释

Lee Chen

算法 大前端 LeetCode

Java训练营第一周习题:01字节码分析

现实中游走

Java 字节码

互联网员工下班时间曝光:所有的光鲜,都有加班的味道

不脱发的程序猿

996 28天写作 二月春节不断更 加班文化 互联网企业

日记 2021年2月23日(周二)

Changing Lin

2月春节不断更

1月干货总结:EasyDL上线时序预测模型,文档翻译全新发布

百度大脑

jenkins Android 自动构建-扫码下载

三爻

基于simhash的文本去重原理

行者AI

Python hash

使用 RxJS 设计实现一个下载中心功能

laoergege

大前端 RXJS

智慧组工党务解决方案,智慧党建系统

13530558032

面试腾讯T3,过关斩将直通3面,终斩获offer流下了激动的泪水(腾讯面经总结分享)

Java架构之路

Java 程序员 架构 面试 编程语言

2021最新总结:阿里/京东/饿了么/拼多多/爱奇艺面经分享(堪称Offer收割机)

比伯

Java 编程 架构 面试 计算机

knysa:异步等待风格PhantomJS脚本编程_JavaScript_Bo Zou_InfoQ精选文章