抽象地談論 Scala 的確有趣,然而一旦將其付諸實踐,就會發現將它作為 「玩具」 與在工作中使用它的區別.Scala 狂熱者 Ted Neward 撰寫了一篇 對 Scitter 的介紹,Scitter 是一個用於訪問 Twitter 的 Scala 庫,本文是其後續篇,在本文中,Ted Neward 為這個客戶機庫提供了一組更有趣也更有用的特性.
歡迎回來,Scala 迷們.上個月,我們談到了 Twitter,這個微博客站點目前正引起社會性網路的極大興趣,我們還談到它的基於 XML-/REST 的 API 如何使它成為開發人員進行研究和探索的一個有趣平台.為此,我們首先充實了 「Scitter」 的基本結構,Scitter 是用於訪問 Twitter 的一個 Scala 庫.
我們對於 Scitter 有幾個目標:
● 簡化 Twitter 訪問,比過去打開 HTTP 連接然後 「手動」 執行操作更容易.
● 可以從 Java 客戶機輕鬆訪問它.
● 輕鬆模擬以便進行測試.
在這一期,我們不必完成整個 Twitter API,但是我們將完成一些核心部分,目的是讓這個庫達到公共源代碼控制庫的程度,便於其他人來完成這項工作.
到目前為止:Scitter 0.1
首先我們簡單回顧一下到目前為止我們所處的階段:
清單 1. Scitter v0.1
package com.tedneward.scitter { import org.apache.commons.httpclient._, auth._, methods._, params._ import scala.xml._ /** * Status message type. This will typically be the most common message type * sent back from Twitter (usually in some kind of collection form). Note * that all optional elements in the Status type are represented by the * Scala Option[T] type, since that's what it's there for. */ abstract class Status { /** * Nested User type. This could be combined with the top-level User type, * if we decide later that it's OK for this to have a boatload of optional * elements, including the most-recently-posted status update (which is a * tad circular). */ abstract class User { val id : Long val name : String val screenName : String val description : String val location : String val profileImageUrl : String val url : String val protectedUpdates : Boolean val followersCount : Int } /** * Object wrapper for transforming (format) into User instances. */ object User { /* def fromAtom(node : Node) : Status = { } */ /* def fromRss(node : Node) : Status = { } */ def fromXml(node : Node) : User = { new User { val id = (node "id").text.toLong val name = (node "name").text val screenName = (node "screen_name").text val description = (node "description").text val location = (node "location").text val profileImageUrl = (node "profile_image_url").text val url = (node "url").text val protectedUpdates = (node "protected").text.toBoolean val followersCount = (node "followers_count").text.toInt } } } val createdAt : String val id : Long val text : String val source : String val truncated : Boolean val inReplyToStatusId : Option[Long] val inReplyToUserId : Option[Long] val favorited : Boolean val user : User } /** * Object wrapper for transforming (format) into Status instances. */ object Status { /* def fromAtom(node : Node) : Status = { } */ /* def fromRss(node : Node) : Status = { } */ def fromXml(node : Node) : Status = { new Status { val createdAt = (node "created_at").text val id = (node "id").text.toLong val text = (node "text").text val source = (node "source").text val truncated = (node "truncated").text.toBoolean val inReplyToStatusId = if ((node "in_reply_to_status_id").text != "") Some((node "in_reply_to_status_id").text.toLong) else None val inReplyToUserId = if ((node "in_reply_to_user_id").text != "") Some((node "in_reply_to_user_id").text.toLong) else None val favorited = (node "favorited").text.toBoolean val user = User.fromXml((node "user")(0)) } } } /** * Object for consuming "non-specific" Twitter feeds, such as the public timeline. * Use this to do non-authenticated requests of Twitter feeds. */ object Scitter { /** * Ping the server to see if it's up and running. * * Twitter docs say: * test * Returns the string "ok" in the requested format with a 200 OK HTTP status code. * URL: http://twitter.com/help/test.format * Formats: xml, json * Method(s): GET */ def test : Boolean = { val client = new HttpClient() val method = new GetMethod("http://twitter.com/help/test.xml") method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) client.executeMethod(method) val statusLine = method.getStatusLine() statusLine.getStatusCode() == 200 } /** * Query the public timeline for the most recent statuses. * * Twitter docs say: * public_timeline * Returns the 20 most recent statuses from non-protected users who have set * a custom user icon. Does not require authentication. Note that the * public timeline is cached for 60 seconds so requesting it more often than * that is a waste of resources. * URL: http://twitter.com/statuses/public_timeline.format * Formats: xml, json, rss, atom * Method(s): GET * API limit: Not applicable * Returns: list of status elements */ def publicTimeline : List[Status] = { import scala.collection.mutable.ListBuffer val client = new HttpClient() val method = new GetMethod("http://twitter.com/statuses/public_timeline.xml") method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) client.executeMethod(method) val statusLine = method.getStatusLine() if (statusLine.getStatusCode() == 200) { val responseXML = XML.loadString(method.getResponseBodyAsString()) val statusListBuffer = new ListBuffer[Status] for (n <- (responseXML \ "status").elements) statusListBuffer = (Status.fromXml(n)) statusListBuffer.toList } else { Nil } } } /** * Class for consuming "authenticated user" Twitter APIs. Each instance is * thus "tied" to a particular authenticated user on Twitter, and will * behave accordingly (according to the Twitter API documentation). */ class Scitter(username : String, password : String) { /** * Verify the user credentials against Twitter. * * Twitter docs say: * verify_credentials * Returns an HTTP 200 OK response code and a representation of the * requesting user if authentication was successful; returns a 401 status * code and an error message if not. Use this method to test if supplied * user credentials are valid. * URL: http://twitter.com/account/verify_credentials.format * Formats: xml, json * Method(s): GET */ def verifyCredentials : Boolean = { val client = new HttpClient() val method = new GetMethod("http://twitter.com/help/test.xml") method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) client.getParams().setAuthenticationPreemptive(true) val creds = new UsernamePasswordCredentials(username, password) client.getState().setCredentials( new AuthScope("twitter.com", 80, AuthScope.ANY_REALM), creds) client.executeMethod(method) val statusLine = method.getStatusLine() statusLine.getStatusCode() == 200 } } } |
代碼有點長,但是很容易分為幾個基本部分:
● case 類 User 和 Status,表示 Twitter 在對 API 調用的響應中發回給客戶機的基本類型,包括用於構造或提取 XML 的一些方法.
● 一個 Scitter 獨立對象,處理那些不需要對用戶進行驗證的操作.
● 一個 Scitter 實例(用 username 和 password 參數化),用於那些需要對用戶執行驗證的操作.
到目前為止,對於這兩種 Scitter 類型,我們只談到了測試、verifyCredentials 和 public_timeline API.雖然這些有助於確定 HTTP 訪問的基礎(使用 Apache HttpClient 庫)可以工作,並且我們將 XML 響應轉換成 Status 對象的基本方式也是可行的,但是現在我們甚至不能進行基本的 「我的朋友在說什麼」 的公共時間線查詢,也沒有採取過基本的措施來防止代碼庫中出現 「重複」 問題,更不用說尋找一些方法來模擬用於測試的網路訪問代碼.
顯然,在這一期我們有很多事情要做.
連接
對於代碼,第一件讓我煩惱的事就是,我在 Scitter 對象和類的每個方法中都重複這樣的操作序列:創建 HttpClient 實例,對它進行初始化,用必要的驗證參數對它進行參數化,等等.當它們只有 3 個方法時,可以進行管理,但是顯然不易於伸縮,以後還會增加很多方法.此外,以後重新在那些方法中引入模擬和/或本地/離線測試功能將十分困難.我們要解決這個問題.
實際上,我們這裡介紹的並不是 Scala 本身,而是不要重複自己(Don't-Repeat-Yourself)的思想.因此,我將從基本的面向對象方法開始:創建一個 helper 方法,用於做實際工作:
清單 2. 對代碼庫執行 DRY 原則
package com.tedneward.scitter { // ... object Scitter { // ... private[scitter] def exec ute(url : String) = { val client = new HttpClient() val method = new GetMethod(url) method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) client.executeMethod(method) (method.getStatusLine().getStatusCode(), method.getResponseBodyAsString()) } } } |
注意兩點:首先,我從 execute() 方法返回一個元組,其中包含狀態碼和響應主體.這正是讓元組成為語言中固有的一部分的一個強大之處,實際上很容易從一個方法調用返回多個返回值.當然,在 Java 代碼中,也可以通過創建包含元組元素的頂級或嵌套類來實現這一點,但是這需要一整套專用於這一個方法的代碼.此外,本來也可以返回一個包含 String 鍵和 Object 值的 Map,但是那樣就在很大程度上喪失了類型安全性.元組並不是一個非常具有變革性的特性,它只不過是又一個使 Scala 成為強大語言的優秀特性.
由於使用元組,我需要使用 Scala 的另一個特色語法將兩個結果都捕捉到本地變數中,就像下面這個重寫后的 Scitter.test 那樣:
清單 3. 這符合 DRY 原則嗎?
package com.tedneward.scitter { // ... object Scitter { /** * Ping the server to see if it's up and running. * * Twitter docs say: * test * Returns the string "ok" in the requested format with a 200 OK HTTP status code. * URL: http://twitter.com/help/test.format * Formats: xml, json * Method(s): GET */ def test : Boolean = { val (statusCode, statusBody) = execute("http://twitter.com/statuses/public_timeline.xml") statusCode == 200 } } } |
實際上,我可以輕鬆地將 statusBody 全部去掉,並用 _ 替代它,我沒有用過第二個參數(test 沒有返回 statusBody),但是對於其他調用將需要這個 statusBody,出於演示的目的,我保留了該參數.
注意,execute() 沒有泄露任何與實際 HTTP 通信相關的細節 — 這是 Encapsulation 101.這樣便於以後用其他實現替換 execute()(以後的確要這麼做),或者便於通過重用 HttpClient 對象來優化代碼,而不是每次重新實例化新的對象.
接下來,注意到 execute() 方法在 Scitter 對象上嗎?這意味著我將可以從不同的 Scitter 實例中使用它(至少現在可以這樣做,如果以後在 execute() 內部執行的操作不允許這樣做,則另當別論)— 這就是我將 execute() 標記為 private[scitter] 的原因,這意味著 com.tedneward.scitter 包中的所有內容都可以看到它.
(順便說一句,如果還沒有運行測試的話,那麼請運行測試,確保一切運行良好.我將假設我們在討論代碼時您會運行測試,如果我忘了提醒您,並不意味著您也忘記這麼做.)
順便說一句,對於經過驗證的訪問,為了支持 Scitter 類,需要一個用戶名和密碼,我將創建一個重載的 execute() 方法,該方法新增兩個 String 參數:
清單 4. 更加 DRY 化的版本
package com.tedneward.scitter { // ... object Scitter { // ... private[scitter] def execute(url : String, username : String, password : String) = { val client = new HttpClient() val method = new GetMethod(url) method.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler(3, false)) 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()) } } } |
實際上,除了驗證部分,這兩個 execute() 基本上是做相同的事情,我們可以按照第二個版本完全重寫第一個 execute(),但是要注意,Scala 要求顯式表明重載的 execute() 的返回類型:
清單 5. 放棄 DRY
package com.tedneward.scitter { // ... object Scitter { // ... private[scitter] def execute(url : String) : (Int, String) = execute(url, "", "") private[scitter] def execute(url : String, username : String, password : String) = { val client = new HttpClient() val method = new GetMethod(url) 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()) } } } |
到目前為止,一切良好.我們對 Scitter 的通信部分進行了 DRY 化處理,接下來我們轉移到下一件事情:獲得朋友的 tweet 的列表.
連接(到朋友)
Twitter API 表明,friends_timeline API 調用 「返回認證用戶和該用戶的朋友發表的最近 20 條狀態」.(它還指出,對於直接從 Twitter Web 站點使用 Twitter 的用戶,「這相當於 Web 上的 『/home』」.)對於任何 Twitter API 來說,這是非常基本的要求,讓我們將它添加到 Scitter 類中.之將它添加到類而不是對象中,是正如文檔中指出的那樣,這是以驗證用戶的身份做的事情,而我已經決定歸入 Scitter 類,而不是 Scitter 對象.
但是,這裡我們碰到一塊絆腳石:friends_timeline 調用接受一組 「可選參數」,包括 since_id、max_id、count 和 page,以控制返回的結果.這是一項比較複雜的操作, Scala 不像其他語言(例如 Groovy、JRuby 或 JavaScript)那樣原生地支持 「可選參數」 的思想,但是我們首先來處理簡單的東西 — 我們來創建一個 friendsTimeline 方法,該方法只執行一般的、非參數化的調用:
清單 6.「告訴我你身邊的朋友是怎樣的……」
package com.tedneward.scitter { class Scitter { def friendsTimeline : List[Status] = { val (statusCode, statusBody) = Scitter.execute("http://twitter.com/statuses/friends_timeline.xml", username, password) if (statusCode == 200) { val responseXML = XML.loadString(statusBody) val statusListBuffer = new ListBuffer[Status] for (n <- (responseXML \ "status").elements) statusListBuffer = (Status.fromXml(n)) statusListBuffer.toList } else { Nil } } } } |
到目前為止,一切良好.用於測試的相應方法看上去如下所示:
清單 7. 「我能判斷您是怎樣的人 」(Miguel de Cervantes)
package com.tedneward.scitter.test { class ScitterTests { // ... @Test def scitterFriendsTimeline = { val scitter = new Scitter(testUser, testPassword) val result = scitter.friendsTimeline assertTrue(result.length > 0) } } } |
好極了.看上去就像 Scitter 對象中的 publicTimeline() 方法,並且行為也幾乎完全相同.
對於我們來說,那些可選參數仍然有問題. Scala 並沒有可選參數這樣的語言特性,乍一看來,惟一的選擇就是完整地創建重載的 friendsTimeline() 方法,讓該方法帶有一定數量的參數.
幸運的是,還有一種更好的方式,即通過一種有趣的方式將 Scala 的兩個語言特性(有一個特性我還沒有提到過) — case 類和 「重複參數」 結合起來(見清單 8):
清單 8. 「我有多愛你?……」
package com.tedneward.scitter { // ... abstract class OptionalParam case class Id(id : String) extends OptionalParam case class UserId(id : Long) extends OptionalParam case class Since(since_id : Long) extends OptionalParam case class Max(max_id : Long) extends OptionalParam case class Count(count : Int) extends OptionalParam case class Page(page : Int) extends OptionalParam class Scitter(username : String, password : String) { // ... def friendsTimeline(options : OptionalParam*) : List[Status] = { val optionsStr = new StringBuffer("http://twitter.com/statuses/friends_timeline.xml?") for (option <- options) { option match { case Since(since_id) => optionsStr.append("since_id=" since_id.toString() "&") case Max(max_id) => optionsStr.append("max_id=" max_id.toString() "&") case Count(count) => optionsStr.append("count=" count.toString() "&") case Page(page) => optionsStr.append("page=" page.toString() "&") } } val (statusCode, statusBody) = Scitter.execute(optionsStr.toString(), username, password) if (statusCode == 200) { val responseXML = XML.loadString(statusBody) val statusListBuffer = new ListBuffer[Status] for (n <- (responseXML \ "status").elements) statusListBuffer = (Status.fromXml(n)) statusListBuffer.toList } else { Nil } } } } |
看到標在選項參數後面的 * 嗎?這表明該參數實際上是一個參數序列,這類似於 Java 5 中的 varargs 結構.和 varargs 一樣,傳遞的參數數量可以像前面那樣為 0(不過,我們將需要回到測試代碼,向 friendsTimeline 調用增加一對括弧,否則編譯器無法作出判斷:是調用不帶參數的方法,還是出於部分應用之類的目的而調用該方法);我們還可以開始傳遞那些 case 類型,如下面的清單所示:
清單 9. 「……聽我細細說」(William Shakespeare)
package com.tedneward.scitter.test { class ScitterTests { // ... @Test def scitterFriendsTimelineWithCount = { val scitter = new Scitter(testUser, testPassword) val result = scitter.friendsTimeline(Count(5)) assertTrue(result.length == 5) } } } |
當然,總是存在這樣的可能性:客戶機傳入古怪的參數序列,例如 friendsTimeline(Count(5), Count(6), Count(7)),但是在這裡,我們只是將列表傳遞給 Twitter(希望它們的錯誤處理足夠強大,能夠只採用指定的那個參數).當然,如果真的擔心這一點,也很容易在構造發送到 Twitter 的 URL 之前,從頭至尾檢查重複參數列表,並採用指定的每種參數的一個參數.不過,後果自負.
兼容性
但是,這又產生一個有趣的問題:從 Java 代碼調用這個方法有多容易?畢竟,如果這個庫的主要目標之一是維護與 Java 代碼的兼容性,那麼我們需要確保 Java 代碼在使用它時不至於太麻煩.
我們首先通過我們的好朋友 javap 檢驗一下 Scitter 類:
清單 10. 哦,沒錯,Java 代碼……我現在想起來了……
C:>javap -classpath classes com.tedneward.scitter.Scitter Compiled from "scitter.scala" public class com.tedneward.scitter.Scitter extends java.lang.Object implements s cala.ScalaObject{ public com.tedneward.scitter.Scitter(java.lang.String, java.lang.String); public scala.List friendsTimeline(scala.Seq); public boolean verifyCredentials(); public int $tag() throws java.rmi.RemoteException; } |
這時我心中有兩點擔心.首先,friendsTimeline() 帶有一個 scala.Seq 參數(這是我們剛才用過的重複參數特性).其次,friendsTimeline() 方法和 Scitter 對象中的 publicTimeline() 方法一樣(如果不信,可以運行 javap 查證),返回一個元素列表 scala.List.這兩種類型在 Java 代碼中有多好用?
最簡單的方法是用 Java 代碼而不是 Scala 編寫一組小型的 JUnit 測試,接下來我們就這樣做.雖然可以測試 Scitter 實例的構造,並調用它的 verifyCredentials() 方法,但這些並不是特別有用 — 記住,我們不是要驗證 Scitter 類的正確性,而是要看看從 Java 代碼中使用它有多容易.為此,我們直接編寫一個測試,該測試將獲取 「friends timeline」 — 換句話說,我們要實例化一個 Scitter 實例,並且不使用任何參數來調用它的 friendsTimeline() 方法.
這有點複雜,需要傳入 scala.Seq 參數 — scala.Seq 是一個 Scala 特性,它將映射到底層 JVM 中的一個介面,不能直接實例化.我們可以嘗試典型的 Java null 參數,但是這樣做會在運行時拋出異常.我們需要的是一個 scala.Seq 類,以便從 Java 代碼中輕鬆地實例化這個類.
最終,我們還是在 mutable.ListBuffer 類型中找到一個這樣的類,這正是在 Scitter 實現本身中使用的類型:
清單 11. 現在我明白了自己為什麼喜歡 Scala……
package com.tedneward.scitter.test; import org.junit.*; import com.tedneward.scitter.*; public class JavaScitterTests { public static final String testUser = "TESTUSER"; public static final String testPassword = "TESTPASSWORD"; @Test public void getFriendsStatuses() { Scitter scitter = new Scitter(testUser, testPassword); if (scitter.verifyCredentials()) { scala.List statuses = scitter.friendsTimeline(new scala.collection.mutable.ListBuffer()); Assert.assertTrue(statuses.length() > 0); } else Assert.assertTrue(false); } } |
使用返回的 scala.List 不是問題,我們可以像對待其他 Collection 類一樣對待它(不過我們的確懷念 Collection 的一些優點, List 上基於 Scala 的方法都假設您將從 Scala 中與它們交互),,遍歷結果並不難,只要用上一點 「舊式」 Java 代碼(大約 1995 年時候的風格):
清單 12. 重回 1995,又見 Vector……
package com.tedneward.scitter.test; import org.junit.*; import com.tedneward.scitter.*; public class JavaScitterTests { public static final String testUser = "TESTUSER"; public static final String testPassword = "TESTPASSWORD"; @Test public void getFriendsStatuses() { Scitter scitter = new Scitter(testUser, testPassword); if (scitter.verifyCredentials()) { scala.List statuses = scitter.friendsTimeline(new scala.collection.mutable.ListBuffer()); Assert.assertTrue(statuses.length() > 0); for (int i=0; i<statuses.length(); i ) { Status stat = (Status)statuses.apply(i); System.out.println(stat.user().screenName() " said " stat.text()); } } else Assert.assertTrue(false); } } |
這將我們引向另一個部分,即將參數傳遞到 friendsTimeline() 方法.不幸的是,ListBuffer 類型不是將一個集合作為構造函數參數,我們必須構造參數列表,然後將集合傳遞到方法調用.這樣有些單調乏味,但還可以承受:
清單 13. 現在可以回到 Scala 嗎?
package com.tedneward.scitter.test; import org.junit.*; import com.tedneward.scitter.*; public class JavaScitterTests { public static final String testUser = "TESTUSER"; public static final String testPassword = "TESTPASSWORD"; // ... @Test public void getFriendsStatusesWithCount() { Scitter scitter = new Scitter(testUser, testPassword); if (scitter.verifyCredentials()) { scala.collection.mutable.ListBuffer params = new scala.collection.mutable.ListBuffer(); params.$plus$eq(new Count(5)); scala.List statuses = scitter.friendsTimeline(params); Assert.assertTrue(statuses.length() > 0); Assert.assertTrue(statuses.length() == 5); for (int i=0; i<statuses.length(); i ) { Status stat = (Status)statuses.apply(i); System.out.println(stat.user().screenName() " said " stat.text()); } } else Assert.assertTrue(false); } } |
,雖然 Java 版本比對應的 Scala 版本要冗長一點,但是到目前為止,從任何要使用 Scitter 庫的 Java 客戶機中調用該庫仍然非常簡單.好極了.
結束語
顯然,對於 Scitter 還有很多事情要做,但是它已經逐漸豐滿起來,感覺不錯.我們設法對 Scitter 庫的通信部分進行了 DRY 化處理,並且為 Twitter 提供的很多不同的 API 調用合併了所需的可選參數 — 到目前為止,Java 客戶機基本上沒有受到我們公布的 API 的拖累.即使 API 沒有 Scala 所使用的那些 API 那樣乾淨,但是如果 Java 開發人員要使用 Scitter 庫,也不需要費太多力氣.
Scitter 庫仍然帶有對象的意味,不過我們也開始看到,一些有實用意義的 Scala 特性正在出現.隨著我們繼續構建這個庫,只要有助於使代碼更簡潔、更清晰,越來越多這樣的特性將添加進來.本應如此.
是時候說再見了,我要短暫離開一下.等我回來時,我將為這個庫增加對離線測試的支持,並增加更新用戶狀態的功能.到那時,Scala 迷們,請記住:功能正常總比功能失常好.(對不起,我只是太喜歡開玩笑了.)
[火星人 ] 面向Java開發人員的Scala指南: 增強Scitter庫已經有815次圍觀