从Java走进Scala:Twitter API与Scala的交互
本文是IBMDW上Ted Neward的Scala教学系列,本文是第16篇,标题为《用 Scitter 更新 Twitter》。
51CTO编辑推荐:Scala编程语言专题
在撰写本文时,夏季即将结束,新的学年就要开始,Twitter 的服务器上不断涌现出世界各地的网虫和非网虫们发布的更新。对于我们很多身在北美的人来说,从海滩聚会到足球,从室外娱乐到室内项目,各种各样的想法纷至沓来。为了跟上这种形势,是时候重访 Scitter 这个用于访问 Twitter 的 Scala 客户机库了。
如果 到目前为止 您一直紧随 Scitter 的开发,就会知道,这个库现在能够利用各种不同的 Twitter API 查看用户的好友、追随者和时间线,以及其他内容。但是,这个库还不具备发布状态更新的能力。在这最后一篇关于 Scitter 的文章中,我们将丰富这个库的功能,增加一些有趣的内容(终止和评价)功能和重要方法 update()、show() 和 destroy()。在此过程中,您将了解更多关于 Twitter API 的知识,它与 Scala 之间的交互如何,您还将了解如何克服两者之间不可避免的编程挑战。
注意,当您看到本文的时候,Scitter 库将位于一个 公共源代码控制库 中。当然,我还将在本文中包括 源代码,但是要知道,源代码库可能发生改变。换句话说,项目库中的代码与您在这里看到的代码可能略有不同,或者有较大的不同。
POST 到 Twitter
到目前为止,我们的 Scitter 开发主要集中于一些基于 <font face="NSimsun">HTTP GET</font>
的操作,这主要是因为这些调用非常容易,而我想轻松切入 Twitter API。将 <font face="NSimsun">POST</font>
和 <font face="NSimsun">DELETE</font>
操作添加到库中对于可见性来说迈出了重要一步。到目前为止,可以在个人 Twitter 帐户上运行单元测试,而其他人并不知道您要干什么。但是,一旦开始发送更新消息,那么全世界都将知道您要运行 Scitter 单元测试。
如果继续测试 Scitter,那么需要在 Twitter 上创建自己的 “测试” 帐户。(也许用 Twitter API 编程的最大缺点是没有任何合适的测试或模拟工具。)
目前的进展
在开始着手这个库的新的 <font face="NSimsun">UPDATE</font>
功能之前,我们来回顾一下到目前为止我们已经创建的东西。(我不会提供完整的源代码清单,因为 Scitter 已经开始变得过长,不便于全部显示。但是,可以在阅读本文时,从另一个窗口查看 代码。)
大致来说,Scitter 库分为 4 个部分:
- 来回发送的请求和响应类型(
<font face="NSimsun">User</font>
、<font face="NSimsun">Status</font>
等),包含在 API 中;它们被建模为 case 类。 <font face="NSimsun">OptionalParam</font>
类型,同样在 API 中的某些地方;也被建模为 case 类,这些 case 类继承基本的<font face="NSimsun">OptionalParam</font>
类型。<font face="NSimsun">Scitter</font>
对象,用于通信基础和对 Twitter 的匿名(无身份验证)访问。<font face="NSimsun">Scitter</font>
类,存放一个用户名和密码,用于访问给定 Twitter 帐户时进行验证。
注意,在这最后一篇文章中,为了使文件大小保持在相对合理的范围内,我将请求/响应类型分开放到不同的文件中。
终止和评价
那么,现在我们清楚了目标。我们将通过实现两个 “只读” Twitter API 来达到目标:<font face="NSimsun">end_session</font>
API(结束用户会话)和 <font face="NSimsun">rate_limit_status</font>
API(描述在某一特定时段内用户帐户还剩下多少可用的 post)。
<font face="NSimsun">end_session</font>
API 与它的同胞 <font face="NSimsun">verify_credentials</font>
相似,也是一个非常简单的 API:只需用一个经过验证的请求调用它,它将 “结束” 当前正在运行的会话。在 Scitter 类上实现它非常容易,如清单 1 所示:
清单 1. 在 Scitter 上实现 end_session
package com.tedneward.scitter { import org.apache.commons.httpclient._, auth._, methods._, params._ import scala.xml._ // ... class Scitter { /** * */ def endSession : Boolean = { val (statusCode, statusBody) = Scitter.execute("http://twitter.com/account/end_session.xml", username, password) statusCode == 200 } } } |
好吧,我失言了。也不是那么容易。
POST
和我们到目前为止用过的 Twitter API 中的其他 API 不一样,<font face="NSimsun">end_session</font>
要求传入的消息是用 <font face="NSimsun">HTTP POST</font>
语义发送的。现在,<font face="NSimsun">Scitter.execute</font>
方法做任何事情都是通过 <font face="NSimsun">GET</font>
,这意味着需要将那些期望 <font face="NSimsun">GET</font>
的 API 与那些期望 <font face="NSimsun">POST</font>
的 API 区分开来。
现在暂不考虑这一点,另外还有一个明显的变化:<font face="NSimsun">POST</font>
的 API 调用还需将名称/值对传递到 <font face="NSimsun">execute()</font>
方法中。(记住,在其他 API 调用中,若使用 <font face="NSimsun">GET</font>
,则所有参数可以作为查询参数出现在 URL 行;若使用 <font face="NSimsun">POST</font>
,则参数出现在 HTTP 请求的主体中。)在 Scala 中,每当提到名称/值对,自然会想到 Scala <font face="NSimsun">Map</font>
类型,所以在考虑建模作为 <font face="NSimsun">POST</font>
一部分发送的数据元素时,最容易的方法是将它们放入到一个 <font face="NSimsun">Map[String,String]</font>
中并传递。
例如,如果将一个新的状态消息传递给 Twitter,需要将这个不超过 140 个字符的消息放在一个名称/值对 <font face="NSimsun">Status</font>
中,那么应该如清单 2 所示:
清单 2. 基本 map 语法
val map = Map("status" -> message) |
在此情况下,我们可以重构 <font face="NSimsun">Scitter.execute()</font>
方法,使之用 一个 <font face="NSimsun">Map</font>
作为参数。如果 <font face="NSimsun">Map</font>
为空,那么可以认为应该使用 <font face="NSimsun">GET</font>
而不是 <font face="NSimsun">POST</font>
,如清单 3 所示:
清单 3. 重构 execute()
private[scitter] def execute(url : String) : (Int, String) = execute(url, Map(), "", "") private[scitter] def execute(url : String, username : String, password : String) : (Int, String) = execute(url, Map(), username, password) private[scitter] def execute(url : String, dataMap : Map[String,String]) : (Int, String) = execute(url, dataMap, "", "") private[scitter] def execute(url : String, dataMap : Map[String,String], username : String, password : String) = { val client = new HttpClient() val method = if (dataMap.size == 0) { new GetMethod(url) } else { var m = new PostMethod(url) val array = new Array[NameValuePair](dataMap.size) var pos = 0 dataMap.elements.foreach { (pr) => pr match { case (k, v) => array(pos) = new NameValuePair(k, v) } pos += 1 } m.setRequestBody(array) m } method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) if ((username != "") && (password != "")) { client.getParams().setAuthenticationPreemptive(true) client.getState().setCredentials( new AuthScope("twitter.com", 80, AuthScope.ANY_REALM), new UsernamePasswordCredentials(username, password)) } client.executeMethod(method) (method.getStatusLine().getStatusCode(), method.getResponseBodyAsString()) } |
<font face="NSimsun">execute()</font>
方法最大的变化是引入了 <font face="NSimsun">Map[String,String]</font>
参数,以及与它的大小有关的 “if” 测试。该测试决定是处理 <font face="NSimsun">GET</font>
请求还是 <font face="NSimsun">POST</font>
请求。由于 Apache Commons <font face="NSimsun">HttpClient</font>
要求 <font face="NSimsun">POST</font>
请求的主体放在 <font face="NSimsun">NameValuePairs</font>
中,因此我们使用 <font face="NSimsun">foreach()</font>
调用遍历 map 的元素。我们以二元组 pr 的形式传入 map 的键和值,并将它们分别提取到本地绑定变量 k 和 v,然后使用这些值作为 <font face="NSimsun">NameValuePair</font>
构造函数的构造函数参数。
我们还可以使用 <font face="NSimsun">PostMethod</font>
上的 <font face="NSimsun">setParameter(name, value)</font>
API 更轻松地做这些事情。出于教学的目的,我选择了清单 3 中的方法:以表明 Scala 数组和 Java 数组一样,仍然是可变的,即使数组引用被标记为 val 仍是如此。记住,在实际代码中,对于每个 (k,v) 元组,使用 <font face="NSimsun">PostMethod</font>
上的 <font face="NSimsun">setParameter(name, value)</font>
方法要好得多。
还需注意,对于 if/else 返回的 “method” 对象的类型,Scala 编译器会进行 does the right thing 类型推断。由于 Scala 可以看到 if/else 返回的是 <font face="NSimsun">GetMethod</font>
还是 <font face="NSimsun">PostMethod</font>
对象,它会选择最接近的基本类型 <font face="NSimsun">HttpMethodBase</font>
作为 “method” 的返回类型。这也意味着,在 <font face="NSimsun">execute()</font>
方法的其余部分中,<font face="NSimsun">HttpMethodBase</font>
中的任何不可用方法都是不可访问的。幸运的是,我们不需要它们,所以至少现在没有问题。
清单 3 中的实现的背后还潜藏着最后一个问题,这个问题是由这样一个事实引起的:我选择了使用 <font face="NSimsun">Map</font>
来区分 <font face="NSimsun">execute()</font>
方法是处理 <font face="NSimsun">GET</font>
操作,还是处理 <font face="NSimsun">POST</font>
操作。如果还需要使用其他 HTTP 动作(例如 <font face="NSimsun">PUT</font>
或 <font face="NSimsun">DELETE</font>
),那么将不得不再次重构 <font face="NSimsun">execute()</font>
。到目前为止,还没有这样的问题,但是今后要记住这一点。
测试
在实施这样的重构之前,先运行 <font face="NSimsun">ant test</font>
,以确保原有的所有基于 <font face="NSimsun">GET</font>
的请求 API 仍可使用 — 事实确实如此。(这里假设生产 Twitter API 或 Twitter 服务器的可用性没有变化)。一切正常(至少在我的计算机上是这样),所以实现新的 <font face="NSimsun">execute()</font>
方法就非常容易:
清单 4. Scitter v0.3: endSession
def endSession : Boolean = { val (statusCode, statusBody) = Scitter.execute("http://twitter.com/account/end_session.xml", Map("" -> ""), username, password) statusCode == 200 } |
这实在是再简单不过了。
接下来要做的是实现 <font face="NSimsun">rate_limit_status</font>
API,它有两个版本,一个是经过验证的版本,另一个是没有经过验证的版本。我们将该方法实现为 <font face="NSimsun">Scitter</font>
对象和 <font face="NSimsun">Scitter</font>
类上的 <font face="NSimsun">rateLimitStatus</font>
,如清单 5 所示:
清单 5. Scitter v0.3: rateLimitStatus
package com.tedneward.scitter { object Scitter { // ... def rateLimitStatus : Option[RateLimits] = { val url = "http://twitter.com/account/rate_limit_status.xml" val (statusCode, statusBody) = Scitter.execute(url) if (statusCode == 200) { Some(RateLimits.fromXml(XML.loadString(statusBody))) } else { None } } } class Scitter { // ... def rateLimitStatus : Option[RateLimits] = { val url = "http://twitter.com/account/rate_limit_status.xml" val (statusCode, statusBody) = Scitter.execute(url, username, password) if (statusCode == 200) { Some(RateLimits.fromXml(XML.loadString(statusBody))) } else { None } } } } |
我觉得还是很简单。
更新
现在,有了新的 <font face="NSimsun">POST</font>
版本的 HTTP 通信层,我们可以来处理 Twitter API 的中心:<font face="NSimsun">UPDATE</font>
调用。毫不奇怪,需要一个 <font face="NSimsun">POST</font>
,并且至少有一个参数,即 <font face="NSimsun">Status</font>
。
<font face="NSimsun">Status</font>
参数包含要发布到认证用户的 Twitter 提要的不超过 140 个字符的消息。另外还有一个可选参数:<font face="NSimsun">in_reply_to_status_id</font>
,该参数提供另一个更新的 id,执行了 <font face="NSimsun">POST</font>
的更新将回复该更新。
<font face="NSimsun">UPDATE</font>
调用差不多就是这样了,如清单 6 所示:
清单 6. Scitter v0.3: update
package com.tedneward.scitter { class Scitter { // ... def update(message : String, options : OptionalParam*) : Option[Status] = { def optionsToMap(options : List[OptionalParam]) : Map[String, String]= { options match { case hd :: tl => hd match { case InReplyToStatusId(id) => Map("in_reply_to_status_id" -> id.toString) ++ optionsToMap(tl) case _ => optionsToMap(tl) } case List() => Map() } } val paramsMap = Map("status" -> message) ++ optionsToMap(options.toList) val (statusCode, body) = Scitter.execute("http://twitter.com/statuses/update.xml", paramsMap, username, password) if (statusCode == 200) { Some(Status.fromXml(XML.loadString(body))) } else { None } } } } |
也许这个方法中最 “不同” 的部分就是其中定义的嵌套函数 — 与使用 <font face="NSimsun">GET</font>
的其他 Twitter API 调用不同,Twitter 期望传给 <font face="NSimsun">POST</font>
的参数出现在执行 <font face="NSimsun">POST</font>
的主体中,这意味着在调用 <font face="NSimsun">Scitter.execute()</font>
之前需要将它们转换成 <font face="NSimsun">Map</font>
条目。但是,默认的 <font face="NSimsun">Map</font>
(来自 <font face="NSimsun">scala.collections.immutable</font>
)是不可变的,这意味着可以组合 <font face="NSimsun">Map</font>
,但是不能将条目添加到已有的 <font face="NSimsun">Map</font>
中。
解决这个小难题的最容易的方法是递归地处理传入的 <font face="NSimsun">OptionalParam</font>
元素的列表(实际上是一个 <font face="NSimsun">Array[]</font>
)。我们将每个元素拆开,将它转换成各自的 <font face="NSimsun">Map</font>
条目。然后,将一个新的 <font face="NSimsun">Map</font>
(由新创建的 <font face="NSimsun">Map</font>
和从递归调用返回的 <font face="NSimsun">Map</font>
组成)返回到 <font face="NSimsun">optionsToMap</font>
。
然后,将 <font face="NSimsun">OptionalParam</font>
的 <font face="NSimsun">Array[]</font>
传递到 <font face="NSimsun">optionsToMap</font>
嵌套函数。然后,将返回的 <font face="NSimsun">Map</font>
与我们构建的包含 <font face="NSimsun">Status</font>
消息的 <font face="NSimsun">Map</font>
连接起来。最后,将新的 <font face="NSimsun">Map</font>
和用户名、密码一起传递给 <font face="NSimsun">Scitter.execute()</font>
方法,以传送到 Twitter 服务器。
随便说一句,所有这些任务需要的代码并不多,但是需要更多的解释,这是比较优雅的编程方式。
潜在的重构
理论上,传给 <font face="NSimsun">UPDATE</font>
的可选参数与传给其他基于 <font face="NSimsun">GET</font>
的 API 调用的可选参数将受到同等对待;只是结果的格式有所不同(结果是用于 <font face="NSimsun">POST</font>
的名称/值对,而不是用于 URL 的名称/值对)。
如果 Twitter API 需要其他 HTTP 动作支持(<font face="NSimsun">PUT</font>
和/或 <font face="NSimsun">DELETE</font>
就是可能需要的动作),那么总是可以将 HTTP 参数作为特定参数 — 也许又是一组 case 类 — 并让 <font face="NSimsun">execute()</font>
以一个 HTTP 动作、URL、名称/值对的 map 以及(可选)用户名/密码作为 5 个参数。然后,必要时可以将可选参数转换成一个字符串或一组 <font face="NSimsun">POST</font>
参数。这些内容只需记在脑中就行了。
显示
<font face="NSimsun">show</font>
调用接受要检索的 Twitter 状态的 id,并显示 Twitter 状态。和 <font face="NSimsun">UPDATE</font>
一样,这个方法非常简单,无需再作说明,如清单 7 所示:
清单 7. Scitter v0.3: show
package com.tedneward.scitter { class Scitter { // ... def show(id : Long) : Option[Status] = { val (statusCode, body) = Scitter.execute("http://twitter.com/statuses/show/" + id + ".xml", username, password) if (statusCode == 200) { Some(Status.fromXml(XML.loadString(body))) } else { None } } } } |
还有问题吗?
另一种显示方法
如果想再试一下模式匹配,那么可以看看清单 8 中是如何以另一种方式编写 <font face="NSimsun">show()</font>
方法的:
清单 8. Scitter v0.3: show redux
package com.tedneward.scitter { class Scitter { // ... def show(id : Long) : Option[Status] = { Scitter.execute("http://twitter.com/statuses/show/" + id + ".xml", username, password) match { case (200, body) => Some(Status.fromXml(XML.loadString(body))) case (_, _) => None } } } } |
这个版本比起 if/else 版本是否更加清晰,这很大程度上属于审美的问题,但公平而论,这个版本也许更加简洁。(很可能查看代码的人看到 Scala 的 “函数” 部分越多,就认为这个版本越吸引人。)
但是,相对于 if/else 版本,模式匹配版本有一个优势:如果 Twitter 返回新的条件(例如不同的错误条件或来自 HTTP 的响应代码),那么模式匹配版本在区分这些条件时可能更清晰。例如,如果某天 Twitter 决定返回 400 响应代码和一条错误消息(在主体中),以表明某种格式错误(也许是没有正确地重新 Tweet),那么与 if/else 方法相比,模式匹配版本可以更轻松(清晰)地同时测试响应代码和主体的内容。
还应注意,我们还可以使用清单 8 中的方式创建一些局部应用的函数,这些函数只需要 URL 和参数。但是,坦白说,这是一种自找麻烦的解放方案,所以我不会采用。
撤销
我们还想让 Scitter 用户可以撤销刚才执行的动作。为此,需要一个 <font face="NSimsun">destroy</font>
调用,它将删除已发布的 Twitter 状态,如清单 9 所示:
清单 9. Scitter v0.3: destroy
package com.tedneward.scitter { class Scitter { // ... def destroy(id : Long) : Option[Status] = { val paramsMap = Map("id" -> id.toString()) val (statusCode, body) = Scitter.execute("http://twitter.com/statuses/destroy/" + id.toString() + ".xml", paramsMap, username, password) if (statusCode == 200) { Some(Status.fromXml(XML.loadString(body))) } else { None } } def destroy(id : Id) : Option[Status] = destroy(id.id.toLong) } } |
有了这些东西,我们可以考虑将这个 Scitter 客户机库作为 “alpha” 版,至少实现一个简单的 Scitter 客户机。(按照惯例,这个任务就留给您来完成,作为一项 “读者练习”。)
结束语
编写 Scitter 客户机库是一项有趣的工作。虽然不能说 Scitter 已经可以完全用于生产,但是它绝对足以用于实现简单的、基于文本的 Twitter 客户机,这意味着它已经可以投入使用了。要发现什么人可以使用它,哪些特性是需要的,从而使之变得更有用,最好的方法就是将它向公众发布。
我已经将本文和之前关于 Scitter 的文章中的代码作为第一个修订版提交到 Google Code 上的 Scitter 项目主页。欢迎下载和试用这个库,并告诉我您的想法。同时也欢迎提供 bug 报告、修复和建议。