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

qhaw2921 8年前
   <h2><strong>要点</strong></h2>    <ul>     <li> <p>knysa允许异步等待风格的PhantomJS异步编程;</p> </li>     <li> <p>knysa减少对柯里化(curry)的需求;</p> </li>     <li> <p>knysa支持try/catch/finally流程块;</p> </li>     <li> <p>knysa对浏览器的AJAX调用有更好的支持;</p> </li>     <li> <p>knysa试程序流程更加自然;</p> </li>    </ul>    <p>PhantomJS是提供JavaScript API的可编程无头浏览器(无图形界面)。它非常适合页面自动化和测试。其JavaScript API非常优秀,提供了许多高级功能,但同时也陷入了JavaScript常常遇到的“回调地狱(callback hell)”,既深度嵌套的回调。</p>    <p>目前,已经有很多库和框架致力于解决这个问题。对于PhantomJS来说,CasperJS是其中一个流行的解决方案,但是它仅仅减轻了问题,并没有解决问题。knysa从另一方面优雅的解决了这个问题。与类似CasperJS,knysa允许开发者有顺序的编写步骤。不同于CasperJS,knysa不会添加大量的样板代码(如casper.then()等)。</p>    <p>更重要的是,knysa允许开发者使用诸如if/else/while/break/try/catch/finally等代码结构,更加自然的控制程序流程。</p>    <p>让我们使用一个示例来演示嵌套问题和knysa的理念。以下示例是一段CasperJS脚本,其流程是在Google上搜索关键字“CasperJS”,然后检查搜索结果页面上的每个链接到的页面是否包含关键字“CasperJS”:</p>    <ul>     <li> <p>(第9行)打开Google网页,等待页面加载完毕;</p> </li>     <li> <p>(第11行)网页加载完毕后,填充搜索框并提交,然后等待响应;</p> </li>     <li> <p>(第13行)处理响应:</p>      <ul>       <li> <p>(第16、17行)访问响应中的每个链接,并且等待页面加载;</p> </li>       <li> <p>(第18-23行)当链接的页面加载完毕后,检查关键字“CasperJS”是否存在;</p> </li>      </ul> </li>    </ul>    <p>上面的描述非常简单直接,但是CasperJS的嵌套语法使得代码看上去比较复杂。</p>    <pre>  <code class="language-javascript">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();</code></pre>    <p>我们可以看到,第18行的casper.then()嵌套在13行的另外一个casper.then()函数中。这样的嵌套模糊了程序逻辑,使得程序流程混乱。脚本执行过程中,执行流程不是仅仅向前的,程序流程有3个混杂的阶段:</p>    <ol>     <li>阶段1(第9、13、26行):通过使用casper.start()(第9行)和casper.then()(第13行)创建执行步骤(匿名函数)。这些步骤最后通过执行capser.run()(第26行)开始执行。</li>     <li>阶段2(第11、15、16、17、18行):随着步骤的执行,步骤中的代码(匿名行数)被执行。</li>     <li>阶段3(第19、20、21、22行):在原步骤列表中增加更多步骤,并且执行。</li>    </ol>    <p>于是每个嵌套级别增加了一个执行阶段。</p>    <p>由于这些混杂的阶段,脚本中的每行代码和脚本执行顺序不再匹配。例如,13行在第11行前执行。这对于程序来说难以阅读和定位问题。另一个问题是难以增加“if/else”的判断逻辑或者处理任何异常。第三个问题是:第22行的 <strong>links[i]</strong> 总是会打印“undefined”!</p>    <p>这是为什么呢?</p>    <p>因为在阶段3的第22行之前时,变量“i”已经在阶段2中被修改成了 <strong>links.length</strong> 。为了修复这个问题,我们必须采取 <a href="/misc/goto?guid=4959719998929236257" rel="nofollow,noindex">柯里化</a> 方式(10a/18b和22a行)。这里我们使用变量“link”来保存links[i]的值(第18a行),然后执行一个匿名函数来返回另一个匿名函数(第18b行):</p>    <pre>  <code class="language-javascript">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         }());</code></pre>    <p>我们可以看见,通过柯里化,“link”现在有了正确的值,但是柯里化增加了更多的嵌套代码。这太糟糕了,我们能够做的更好吗?</p>    <p>答案是肯定的。</p>    <p>事实上,通过knysa,我们可以做的更好:我们可以完全去除代码中的嵌套和柯里化,脚本将会更加干净和可读,同时程序执行流程也会更加自然。</p>    <p>以下是实现相同功能的knysa脚本(注意我们引入了隐式变量“kflow”和“kflow”上的函数,同时还有一些“knysa_”开头的函数,我们将在后面进行介绍):</p>    <ul>     <li> <p>(第9行)打开Google网页并等待网页加载;</p> </li>     <li> <p>(第10行)在网页加载后,填充和提交搜索表单,并等待响应返回;</p> </li>     <li> <p>(第13行)处理响应:</p>      <ul>       <li> <p>(第14行)访问响应中的每个链接,并等待网页加载完毕;</p> </li>       <li> <p>(第15-18行)当链接对应的页面加载完毕后,检查关键字“CasperJS”是否存在;</p> </li>      </ul> </li>    </ul>    <p>嵌套代码和柯里化都消失了!现在,代码的执行顺序和脚本中的代码行想对应了。这个顺序也和上面描述的流程相同。整个代码流程中只有一个阶段,代码变得可读,问题定位也更方便。</p>    <pre>  <code class="language-javascript">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();</code></pre>    <p>这是什么魔法?魔法位于每个以“knysa_”为前缀的函数(位于第9、10和14行),这些函数都是异步( <strong>async</strong> )执行,knysa等待( <strong>await</strong> )当前异步调用结束,再继续执行下一行。</p>    <p>knysa将每个脚本作为流程,并且在执行时赋予其一个ID。流程对象可以通过隐式变量“ <strong>kflow</strong> ”暴露出来。流程ID可以通过kflow.getId()获取。</p>    <p>kflow提供了一些异步等待风格的浏览器导航行数,如knysa_open、knysa_fill、knysa_click和knysa_evaluate。对于新的网页,knysa_open、knysa_fill和knysa_click行数会等待他们加载结束:</p>    <ol>     <li> <p><strong>knysa_open</strong> (url):打开一个网页;</p> </li>     <li> <p><strong>knysa_click</strong> (selector):触发点击操作;</p> </li>     <li> <p><strong>knysa_fill</strong> (formSelector, values):填充和提交表单</p> </li>    </ol>    <p>knysa_evaluate(func, kflowId[, arg0, arg1, ...]):和PhantomJS的page.evaluate()函数相同,可以在浏览器端(沙盒中)执行包括AJAX调用在内的任意JavaScript。相比于PhantomJS的page.evaluate()函数,knysa_evaluate提升了对AJAX的支持。它挂起脚本执行。为了恢复执行,“回调函数”内部的代码(通常是AJAX调用的成功/失败回调)必须调用“window.callPhantom(data)”,其中“data.kflowId”需设置成“kflowId”。这里有一个来自 <a href="/misc/goto?guid=4959719999017856401" rel="nofollow,noindex">opl.kns</a> 的示例:AJAX请求用于续借图书,脚本执行会在续借响应请求收到后恢复:</p>    <pre>  <code class="language-javascript">oneRenewResult = kflow.knysa_evaluate(renew, kflow.getId(), ...);</code></pre>    <p>其中沙盒中的函数“renew”有以下几行:</p>    <pre>  <code class="language-javascript">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    });</code></pre>    <p>脚本会再AJAX调用结束之后恢复。根据AJAX调用的结果,oneRenewResult将被设置为不同的值:</p>    <ul>     <li> <p>当AJAX调用成功,第8行恢复执行并将oneRenewResult设置为:{ <strong>kflowId</strong> : <strong>kflowId</strong> , status: 'success', data: e}</p> </li>     <li> <p>当AJAX调用失败,第12行恢复执行,并将oneRenewResult设置为:{ <strong>kflowId</strong> : <strong>kflowId</strong> , status: 'failure', data: e}</p> </li>    </ul>    <p>注意:传入window.callPhantom()函数的所有数据都将作为knysa_evaluate()的返回值。</p>    <p>kflow.sleep(milliseconds)是另一个异步等待函数,但是它被knysa特殊处理。</p>    <p>kflow同时也提供一些常规(非异步等待)函数。这些函数直接来自CasperJS API:</p>    <ul>     <li> <p>open(url)</p> </li>     <li> <p>click(selector)</p> </li>     <li> <p>fill(selector)</p> </li>     <li> <p>getHTML(selector, outer)</p> </li>     <li> <p>exists(selector)</p> </li>     <li> <p>download(url, path, method, data)</p> </li>     <li> <p>getElementAttr(selector, attrName)</p> </li>     <li> <p>render(path)</p> </li>     <li> <p>evaluate(func[, arg0, arg1...])</p> </li>    </ul>    <h2><strong>实现自己的异步等待风格函数</strong></h2>    <p>为了实现这个目的,只需要将函数名字加上“knysa_”前缀。这将告知knysa这是一个异步等待风格函数。当这样的函数调用时,脚本执行将会挂起。但是自己实现的异步等待风格函数需要通过调用kflow.resume(data)函数自行恢复脚本执行。当执行恢复时,传给kflow.resume函数的“data”参数将会变成异步等待函数的返回值。这里是一个来自 <a href="/misc/goto?guid=4959719999096185580" rel="nofollow,noindex">resume.kns</a> 的示例:它首先休眠1秒,然后将输入值“num”乘以100并返回:</p>    <pre>  <code class="language-javascript">1 function knysa_f1(kflow, num) {   2     setTimeout(function() {   3         kflow.resume(num * 100);   4     }, 1000);   5     // return num + 10;   6 }</code></pre>    <p>该函数的返回值是传递给kflow.resume()函数的参数,例如num * 100。</p>    <p>重要提示1:在类似异步等待函数中,常规返回值将被忽略。例如,即使第5行没有注释,“return num + 10”语句的结果也会被简单的丢弃。</p>    <p>重要提示2:异步等待风格函数的调用必须是一个单独的语句。可以是:</p>    <pre>  <code class="language-javascript">knysa_my_func(...);  或者  ret = knysa_my_func(...);</code></pre>    <p>也可以作为对象函数使用:</p>    <pre>  <code class="language-javascript">myObj.knysa_my_func(...);  或者  ret = myObj.knysa_my_func(...);</code></pre>    <p>下面的调用方式无法支持:</p>    <pre>  <code class="language-javascript">if (knysa_my_func(...)) ...      可以改成这样:          val = knysa_my_func(...);          if (val) ...  var1 = abc * knysa_my_func(...)      可以改成这样:          val = knysa_my_func(...);          var1 = abc * val;</code></pre>    <p>这里是调用前面定义的knysa_f1函数的示例,其返回值会被赋值到一个变量:</p>    <pre>  <code class="language-javascript">ret = knysa_f1(5);</code></pre>    <p>当这行代码执行时,ret将在1秒延迟后被设置为500。</p>    <h2><strong>异常处理</strong></h2>    <p>knysa的异常处理机制出奇的简单:老式的try/catch/finally结构。这样的基础设施在CasperJS中是缺失的。示例: <a href="/misc/goto?guid=4959719999187478970" rel="nofollow,noindex">try.kns</a> 。</p>    <p>“ <strong>catch</strong> ”示例:以下代码在发生任何异常时渲染一张调试图片。</p>    <pre>  <code class="language-javascript">var err;  // 变量必须在开头定义  ...  try {      ...  } catch (err) {      kflow.render(image_path);      console.log(err.stack);  }</code></pre>    <p>“finally”示例:以下代码确保在发生异常时登出:</p>    <pre>  <code class="language-javascript">// 填充并提交表单,登录网站  kflow.knysa_fill(...);  try {     ...  } finally {     // 打开登出链接以登出     kflow.knysa_open(logout_link);  }</code></pre>    <p>注意事项:</p>    <ol>     <li>“else if”语法不支持,请使用嵌套的“if/else”语句替代;</li>     <li>“for”循环体不能有异步等待函数调用或者“break”语句,请使用“while”循环替代;</li>     <li>所有变量必须在开头定义,包括catch(err)语句中的“err”变量;</li>     <li>隐式变量“kflow”不能用于变量定义;</li>    </ol>    <p>内部工作原理:</p>    <p>knysa脚本在执行前首先会被转换成JavaScript。转换后的脚本是很多步骤的流程,每个步骤一个函数。每个函数的名字被编码上流程控制信息:</p>    <ul>     <li>每个函数都被编号(为了决定执行顺序)。</li>     <li>“ <strong>_async</strong> ”后缀表示脚本执行将会被挂起。脚本执行将会在恰当的条件满足后恢复:例如页面响应接收到,或者AJAX响应接受到等。每个异步等待语句被转换成类似的函数。</li>     <li>“ <strong>_while</strong> ”后缀但中间不包含“ <strong> <em>endwhile</em> </strong> ”的函数名表示while循环的开始。</li>     <li>“ <strong>_while</strong> ”后缀但中间包含“ <strong> <em>endwhile</em> </strong> ”的函数名表示while循环的结束。</li>     <li>虽然没有说明,“if/else/try/catch/finally/break”语句的转化方式和“while”语句类似。</li>    </ul>    <p>下面是之前示例中去Google搜索的knysa脚本转换后的JavaScript脚本:</p>    <pre>  <code class="language-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);</code></pre>    <p>注意1:以上转换后的JavaScript只是为了展示当前的实现细节。knysa的实现可能改变。例如,将来的版本可能会使用Promises。当然,当PhantomJS完全支持ES6的generators或者ES7中的async/await,knysa可能就不再需要。</p>    <p>注意2:虽然knysa减少了通过使用回调来控制脚本执行顺序,knysa本身使用了PhantomJS的回调机制,例如page.onCallback()和page.onLoadFinished()。</p>    <h2><strong>实践时间</strong></h2>    <p>现在我们已经看见通过kynsa来操作PhantomJS是多么容易和自然,为什么不自己尝试呢? knysa 托管在github。我们可以从 示例 开始。我(作者)也期待听到大家的反馈。由于knysa是新项目,还有很多提升空间,欢迎大家能够对项目做出贡献。贡献的方式有多种:</p>    <ol>     <li>处理 ticket ;</li>     <li>提供更多的示例脚本,不论大小;</li>     <li>或者更好的是,共享可以帮助处理日常零活的knysa脚本,这样可以帮助其他人节省时间,提高工作效率;</li>    </ol>    <h2><strong>致谢</strong></h2>    <ol>     <li>uglifyjs1 用于解析knysa脚本并生成响应javascript;</li>     <li>许多“kflow”函数直接从 CasperJS 提取;</li>    </ol>    <p> </p>    <p> </p>    <p>来自:http://www.infoq.com/cn/articles/knysa-phantomjs-async-await</p>    <p> </p>