增强Scitter库

欢迎回来,Scala粉丝。 上个月 ,我们讨论了Twitter(目前在社交网络类型中引起极大关注的微博客网站)以及其基于XML / REST的API如何使其成为开发人员进行研究和探索的有趣场所。 为此,我们开始充实“ Scitter”的基本结构,这是一个用于访问Twitter的Scala库。

对于Scitter,我们有几个目标:

  • 与仅打开HTTP连接并“手动”完成工作相比,使访问Twitter变得更加容易
  • 易于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
    }
  }
}

它有点长,但是很容易提炼成几个基本组件:

  • 案例类“ User和“ Status表示Twitter响应API调用而发送回其客户端的基本类型,并带有构造成XML表示形式和从XML表示形式提取的方法。
  • Scitter单例对象处理那些不需要经过身份验证的用户的操作。
  • Scitter实例(由用户名和密码参数化)用于需要经过身份验证的用户的操作。

到目前为止,在这两种Scitter类型中,我们仅涵盖了test,verifyCredentials和public_timeline API。 虽然这些帮助验证了HTTP访问(使用Apache HttpClient库)的基础是否有效,以及我们将XML响应转换为Status对象的基本形式是否有效,但现在我们甚至无法做到基本的“我的朋友在说什么”公共时间轴查询,甚至我们也没有采取基本步骤来防止代码库中的“重蹈覆辙”这类问题,更不用说开始寻找使模拟网络访问代码更容易进行测试的方法了。

显然,本集还有很长的路要走。

连接中

使代码感到困惑的第一件事是,我重复使用两种Scitter的每个方法中的操作序列来创建HttpClient实例,对其进行初始化,使用必要的身份验证参数对其进行参数化,等等。对象和类。 当这两种方法之间只有三种方法时,它可能是可管理的,但显然不会扩展,还有许多方法可以做。 另外,稍后很难再回到那些方法中,并引入某种模拟和/或本地/离线测试功能。 因此,让我们对其进行修复。

就其本身而言,这实际上并不是一种Scala主义; 这只是一个很好的简单的“不要重复自己”的想法。 因此,我将从一个面向对象的基本方法开始:创建一个辅助方法来完成实际工作:

清单2.干燥代码库
package com.tedneward.scitter
{
  // ...
  object Scitter
  {
    // ...
    private[scitter] def execute(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没有返回主体),但是我需要主体来进行其他调用,因此我将其留在这里示范目的。

请注意, execute()不会泄漏处理实际HTTP通信的任何细节-这就是封装101。这将使以后用另一种实现(稍后将做)或替换成另一种实现更容易地替换execute() 。通过重用单个HttpClient对象而不是每次重新实例化一个新的对象,可能会优化代码。

接下来,请注意Scitter对象上的execute()方法如何? 这意味着我将能够在各种Scitter实例中使用它(至少到目前为止,直到我在execute()execute()阻止我这样做的操作为止)—这就是为什么我标记了execute()作为private[scitter] ,这意味着com.tedneward.scitter包中的所有内容都可以看到。

(顺便说一句,如果您还没有运行,请运行测试以确保一切正常。我将假设您在执行代码时正在这样做,因此,如果我忘记提及它,那并不意味着您会忘记这样做。)

顺便说一句,支持Scitter类将需要用户名和密码才能进行身份验证访问,因此,我将创建一个execute()方法的重载版本,该方法将两个额外的String用作参数:

清单4.一个偶数DRYer版本
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. Desert 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处理,因此让我们继续进行列表中的下一件事情:让我们获得我朋友的推文列表。

连接(与朋友)

Twitter API指出, friends_timeline API调用“返回由身份验证用户和该用户的朋友发布的20个最新状态”。 (它还指出,对于那些直接从Twitter网站使用Twitter的人来说,“这相当于Web上的'/ home'。”)这是任何Twitter API的基本要求,因此让我们将其添加到Scitter类; 我们将其添加到类中,而不是对象中,因为正如文档所指出的那样,它是代表身份验证用户完成的,我确定该用户属于Scitter类而不是Scitter对象。

不过,在这里,我们丢了一个曲线球: friends_timeline调用接受一系列“可选参数”,其中包括since_idmax_idcountpage以控制返回的结果。 这将是一个棘手的操作,因为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.“ ...我会告诉你你是什么。” (米格尔·塞万提斯)
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
      }
    }
  }
}

看到*标记options参数的末尾? 这表明该参数实际上是一个参数序列,非常类似于Java 5 varargs构造。 就像varargs ,传递的参数数量可以像以前一样为零(尽管我们现在需要返回测试代码并在friendsTimeline调用中添加一对括号,否则编译器将不知道是否我们正在尝试不使用任何参数调用该方法,或者尝试将其用于部分应用程序目的或类似目的); 我们还可以开始传递这些案例类型,如下所示:

清单9.“ ...让我数一数二”(威廉·莎士比亚)
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))者总是有可能传入一些真正奇怪的参数序列,例如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;
}

kes! 这里有两件事让我担忧。 首先, 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代码中实例化该类。

事实证明,我们在Scitter实现本身内部使用的mutable.ListBuffer类型中找到一个:

清单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类一样对待它(尽管我们确实错过了Collections API的一些精妙之处,因为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版本更加冗长,但是到目前为止,从任何可能想要使用它的Java客户端调用Scitter库仍然非常简单。 优秀的。

结论

显然,Scitter还有很长的路要走,但是它已经开始真正成形和成形了,到目前为止,感觉还不错。 我们已经设法对Scitter库的通信部分进行了干燥处理,并结合了Twitter要求的可选参数,以要求它提供许多不同的API调用-到目前为止,Java客户端不会太过延迟通过我们公开的API。 诚然,它不像Scala可以自然使用的API那样干净,但是如果Java开发人员想要使用Scitter库,那么他们没有太多的工作要做。

Scitter库仍然具有某种“客观”的感觉,但是我们开始看到一些Scala的“功能性”功能逐渐渗入系统。 随着我们继续构建该库,越来越多的这些功能将在它们可以使代码更简洁,更清晰的任何地方开始泛滥。 这就是应该的。

现在,是时候让我们分开一下了。 当我返回时,将添加一些对脱机测试的支持,以及将用户状态更新到库的功能。 在此之前,Scala爱好者请记住:功能总比功能失常总要好。 (对不起。我太喜欢这个笑话了。)


翻译自: https://www.ibm.com/developerworks/java/library/j-scala06029/index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值