熱點推薦:
您现在的位置: 電腦知識網 >> 編程 >> Java編程 >> JSP教程 >> 正文

面向Java開發人員的Scala指南: 增強Scitter庫

2022-06-13   來源: JSP教程 

  抽象地談論 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

  首先我們簡單回顧一下到目前為止我們所處的階段

  清單 Scitter v

   package comtednewardscitter
{
  import mons_ auth_ methods_ params_
  import scalaxml_

  /**
   * 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 thats what its there for
   */
  abstract class Status
  {
    /**
     * Nested User type This could be combined with the toplevel User type
     * if we decide later that its OK for this to have a boatload of optional
     * elements including the mostrecentlyposted 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)texttoLong
          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)texttoBoolean
          val followersCount = (node \ followers_count)texttoInt
        }
      }
    }
 
    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)texttoLong
        val text = (node \ text)text
        val source = (node \ source)text
        val truncated = (node \ truncated)texttoBoolean
        val inReplyToStatusId =
          if ((node \ in_reply_to_status_id)text != )
            Some((node \in_reply_to_status_id)texttoLong)
          else
            None
        val inReplyToUserId =
          if ((node \ in_reply_to_user_id)text != )
            Some((node \in_reply_to_user_id)texttoLong)
          else
            None
        val favorited = (node \ favorited)texttoBoolean
        val user = UserfromXml((node \ user)())
      }
    }
  }


  /**
   * Object for consuming nonspecific Twitter feeds such as the public timeline
   * Use this to do nonauthenticated requests of Twitter feeds
   */
  object Scitter
  {
    /**
     * Ping the server to see if its up and running
     *
     * Twitter docs say:
     * test
     * Returns the string ok in the requested format with a OK HTTP status code
     * URL:
     * Formats: xml json
     * Method(s): GET
     */
    def test : Boolean =
    {
      val client = new HttpClient()

      val method = new GetMethod()

      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))

      clientexecuteMethod(method)
     
      val statusLine = methodgetStatusLine()
      statusLinegetStatusCode() ==
    }
    /**
     * Query the public timeline for the most recent statuses
     *
     * Twitter docs say:
     * public_timeline
     * Returns the most recent statuses from nonprotected users who have set
     * a custom user icon  Does not require authentication  Note that the
     * public timeline is cached for seconds so requesting it more often than
     * that is a waste of resources
     * URL: _timelineformat
     * Formats: xml json rss atom
     * Method(s): GET
     * API limit: Not applicable
     * Returns: list of status elements    
     */
    def publicTimeline : List[Status] =
    {
      import llectionmutableListBuffer
   
      val client = new HttpClient()

      val method = new GetMethod(_timelinexml)

      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))

      clientexecuteMethod(method)
     
      val statusLine = methodgetStatusLine()
      if (statusLinegetStatusCode() == )
      {
        val responseXML =
          XMLloadString(methodgetResponseBodyAsString())

        val statusListBuffer = new ListBuffer[Status]

        for (n < (responseXML \\ status)elements)
          statusListBuffer += (StatusfromXml(n))
       
        statusListBuffertoList
      }
      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 OK response code and a representation of the
     * requesting user if authentication was successful; returns a status
     * code and an error message if not  Use this method to test if supplied
     * user credentials are valid
     * URL: _credentialsformat
     * Formats: xml json
     * Method(s): GET
     */
    def verifyCredentials : Boolean =
    {
      val client = new HttpClient()

      val method = new GetMethod()

      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))
       
      clientgetParams()setAuthenticationPreemptive(true)
      val creds = new UsernamePasswordCredentials(username password)
      clientgetState()setCredentials(
        new AuthScope( AuthScopeANY_REALM) creds)

      clientexecuteMethod(method)
     
      val statusLine = methodgetStatusLine()
      statusLinegetStatusCode() ==
    }
  }
}

  代碼有點長但是很容易分為幾個基本部分

  ●  case 類 User 和 Status表示 Twitter 在對 API 調用的響應中發回給客戶機的基本類型包括用於構造或提取 XML 的一些方法

  ●  一個 Scitter 獨立對象處理那些不需要對用戶進行驗證的操作

  ●  一個 Scitter 實例(用 username 和 password 參數化)用於那些需要對用戶執行驗證的操作

  到目前為止對於這兩種 Scitter 類型我們只談到了測試verifyCredentials 和 public_timeline API雖然這些有助於確定 HTTP 訪問的基礎(使用 Apache HttpClient 庫)可以工作並且我們將 XML 響應轉換成 Status 對象的基本方式也是可行的但是現在我們甚至不能進行基本的 我的朋友在說什麼 的公共時間線查詢也沒有采取過基本的措施來防止代碼庫中出現 重復 問題更不用說尋找一些方法來模擬用於測試的網絡訪問代碼

  顯然在這一期我們有很多事情要做

  連接

  對於代碼第一件讓我煩惱的事就是我在 Scitter 對象和類的每個方法中都重復這樣的操作序列創建 HttpClient 實例對它進行初始化用必要的驗證參數對它進行參數化等等當它們只有 個方法時可以進行管理但是顯然不易於伸縮而且以後還會增加很多方法此外以後重新在那些方法中引入模擬和/或本地/離線測試功能將十分困難所以我們要解決這個問題

  實際上我們這裡介紹的並不是 Scala 本身而是不要重復自己(DontRepeatYourself)的思想因此我將從基本的面向對象方法開始創建一個 helper 方法用於做實際工作

  清單 對代碼庫執行 DRY 原則

   package comtednewardscitter
{
  //
  object Scitter
  {
    //
    private[scitter] def exec ute(url : String) =
    {
      val client = new HttpClient()
      val method = new GetMethod(url)
     
      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))
       
      clientexecuteMethod(method)
     
      (methodgetStatusLine()getStatusCode() methodgetResponseBodyAsString())
    }
  }
}

  注意兩點首先我從 execute() 方法返回一個元組其中包含狀態碼和響應主體這正是讓元組成為語言中固有的一部分的一個強大之處因為實際上很容易從一個方法調用返回多個返回值當然在 Java 代碼中也可以通過創建包含元組元素的頂級或嵌套類來實現這一點但是這需要一整套專用於這一個方法的代碼此外本來也可以返回一個包含 String 鍵和 Object 值的 Map但是那樣就在很大程度上喪失了類型安全性元組並不是一個非常具有變革性的特性它只不過是又一個使 Scala 成為強大語言的優秀特性

  由於使用元組我需要使用 Scala 的另一個特色語法將兩個結果都捕捉到本地變量中就像下面這個重寫後的 Scittertest 那樣

  清單 這符合 DRY 原則嗎?

   package comtednewardscitter
{
  //
  object Scitter
  {
    /**
     * Ping the server to see if its up and running
     *
     * Twitter docs say:
     * test
     * Returns the string ok in the requested format with a OK HTTP status code
     * URL:
     * Formats: xml json
     * Method(s): GET
     */
    def test : Boolean =
    {
      val (statusCode statusBody) =
        execute(_timelinexml)
      statusCode ==
    }
  }
}

  實際上我可以輕松地將 statusBody 全部去掉並用 _ 替代它因為我沒有用過第二個參數(test 沒有返回 statusBody)但是對於其他調用將需要這個 statusBody所以出於演示的目的我保留了該參數

  注意execute() 沒有洩露任何與實際 HTTP 通信相關的細節 — 這是 Encapsulation 這樣便於以後用其他實現替換 execute()(以後的確要這麼做)或者便於通過重用 HttpClient 對象來優化代碼而不是每次重新實例化新的對象

  接下來注意到 execute() 方法在 Scitter 對象上嗎?這意味著我將可以從不同的 Scitter 實例中使用它(至少現在可以這樣做如果以後在 execute() 內部執行的操作不允許這樣做則另當別論)— 這就是我將 execute() 標記為 private[scitter] 的原因這意味著 comtednewardscitter 包中的所有內容都可以看到它

  (順便說一句如果還沒有運行測試的話那麼請運行測試確保一切運行良好我將假設我們在討論代碼時您會運行測試所以如果我忘了提醒您並不意味著您也忘記這麼做

  順便說一句對於經過驗證的訪問為了支持 Scitter 類需要一個用戶名和密碼所以我將創建一個重載的 execute() 方法該方法新增兩個 String 參數

  清單 更加 DRY 化的版本

   package comtednewardscitter
{
  //
  object Scitter
  {
    //
    private[scitter] def execute(url : String username : String password : String) =
    {
      val client = new HttpClient()
      val method = new GetMethod(url)
     
      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))
       
  clientgetParams()setAuthenticationPreemptive(true)
  clientgetState()setCredentials(
new AuthScope( AuthScopeANY_REALM)
  new UsernamePasswordCredentials(username password))
     
      clientexecuteMethod(method)
     
      (methodgetStatusLine()getStatusCode() methodgetResponseBodyAsString())
    }
  }
}

  實際上除了驗證部分這兩個 execute() 基本上是做相同的事情我們可以按照第二個版本完全重寫第一個 execute()但是要注意Scala 要求顯式表明重載的 execute() 的返回類型

  清單 放棄 DRY

   package comtednewardscitter
{
  //
  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)
     
      methodgetParams()setParameter(HttpMethodParamsRETRY_HANDLER
        new DefaultHttpMethodRetryHandler( false))
       
      if ((username != ) && (password != ))
      {
        clientgetParams()setAuthenticationPreemptive(true)
        clientgetState()setCredentials(
          new AuthScope( AuthScopeANY_REALM)
            new UsernamePasswordCredentials(username password))
      }
     
      clientexecuteMethod(method)
     
      (methodgetStatusLine()getStatusCode() methodgetResponseBodyAsString())
    }
  }
}

  到目前為止一切良好我們對 Scitter 的通信部分進行了 DRY 化處理接下來我們轉移到下一件事情獲得朋友的 tweet 的列表

  連接(到朋友)

  Twitter API 表明friends_timeline API 調用 返回認證用戶和該用戶的朋友發表的最近 條狀態(它還指出對於直接從 Twitter Web 站點使用 Twitter 的用戶這相當於 Web 上的 /home)對於任何 Twitter API 來說這是非常基本的要求所以讓我們將它添加到 Scitter 類中之所以將它添加到類而不是對象中是因為正如文檔中指出的那樣這是以驗證用戶的身份做的事情而我已經決定歸入 Scitter 類而不是 Scitter 對象

  但是這裡我們碰到一塊絆腳石friends_timeline 調用接受一組 可選參數包括 since_idmax_idcount 和 page以控制返回的結果這是一項比較復雜的操作因為 Scala 不像其他語言(例如 GroovyJRuby 或 JavaScript)那樣原生地支持 可選參數 的思想但是我們首先來處理簡單的東西 — 我們來創建一個 friendsTimeline 方法該方法只執行一般的非參數化的調用

  清單 告訴我你身邊的朋友是怎樣的……

   package comtednewardscitter
{
  class Scitter
  {
    def friendsTimeline : List[Status] =
    {
      val (statusCode statusBody) =
       Scitterexecute(_timelinexml
                        username password)

      if (statusCode == )
      {
        val responseXML = XMLloadString(statusBody)

        val statusListBuffer = new ListBuffer[Status]

        for (n < (responseXML \\ status)elements)
          statusListBuffer += (StatusfromXml(n))
       
        statusListBuffertoList
      }
      else
      {
        Nil
      }
    }
  }
}

  到目前為止一切良好用於測試的相應方法看上去如下所示

  清單 我能判斷您是怎樣的人 (Miguel de Cervantes)

   package comtednewardscittertest
{
  class ScitterTests
  {
    //

    @Test def scitterFriendsTimeline =
    {
      val scitter = new Scitter(testUser testPassword)
      val result = scitterfriendsTimeline
      assertTrue(resultlength > )
    }
  }
}

  好極了看上去就像 Scitter 對象中的 publicTimeline() 方法並且行為也幾乎完全相同

  對於我們來說那些可選參數仍然有問題因為 Scala 並沒有可選參數這樣的語言特性乍一看來惟一的選擇就是完整地創建重載的 friendsTimeline() 方法讓該方法帶有一定數量的參數

  幸運的是還有一種更好的方式即通過一種有趣的方式將 Scala 的兩個語言特性(有一個特性我還沒有提到過) — case 類和 重復參數 結合起來(見清單

  清單 我有多愛你?……

   package comtednewardscitter
{
  //
 
  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(_timelinexml?)
      for (option < options)
      {
        option match
        {
          case Since(since_id) =>
            optionsStrappend(since_id= + since_idtoString() + &)
          case Max(max_id) =>
            optionsStrappend(max_id= + max_idtoString() + &)
          case Count(count) =>
            optionsStrappend(count= + counttoString() + &)
          case Page(page) =>
            optionsStrappend(page= + pagetoString() + &)
        }
      }
     
      val (statusCode statusBody) =
        Scitterexecute(optionsStrtoString() username password)
      if (statusCode == )
      {
        val responseXML = XMLloadString(statusBody)

        val statusListBuffer = new ListBuffer[Status]

        for (n < (responseXML \\ status)elements)
          statusListBuffer += (StatusfromXml(n))
       
        statusListBuffertoList
      }
      else
      {
        Nil
      }
    }
  }
}

  看到標在選項參數後面的 * 嗎?這表明該參數實際上是一個參數序列這類似於 Java 中的 varargs 結構和 varargs 一樣傳遞的參數數量可以像前面那樣為 (不過我們將需要回到測試代碼向 friendsTimeline 調用增加一對括號否則編譯器無法作出判斷是調用不帶參數的方法還是出於部分應用之類的目的而調用該方法)我們還可以開始傳遞那些 case 類型如下面的清單所示

  清單 ……聽我細細說(William Shakespeare)

   package comtednewardscittertest
{
  class ScitterTests
  {
    //

    @Test def scitterFriendsTimelineWithCount =
    {
      val scitter = new Scitter(testUser testPassword)
      val result = scitterfriendsTimeline(Count())
      assertTrue(resultlength == )
    }
  }
}

  當然總是存在這樣的可能性客戶機傳入古怪的參數序列例如 friendsTimeline(Count( Count( Count())但是在這裡我們只是將列表傳遞給 Twitter(希望它們的錯誤處理足夠強大能夠只采用指定的最後那個參數)當然如果真的擔心這一點也很容易在構造發送到 Twitter 的 URL 之前從頭至尾檢查重復參數列表並采用指定的每種參數的最後一個參數不過後果自負

  兼容性

  但是這又產生一個有趣的問題從 Java 代碼調用這個方法有多容易?畢竟如果這個庫的主要目標之一是維護與 Java 代碼的兼容性那麼我們需要確保 Java 代碼在使用它時不至於太麻煩

  我們首先通過我們的好朋友 javap 檢驗一下 Scitter 類

  清單 沒錯Java 代碼……我現在想起來了……

   C:\>javap classpath classes comtednewardscitterScitter
Compiled from scitterscala
public class comtednewardscitterScitter extends javalangObject implements s
calaScalaObject{
    public comtednewardscitterScitter(javalangString javalangString);
    public scalaList friendsTimeline(scalaSeq);
    public boolean verifyCredentials();
    public int $tag()       throws javarmiRemoteException;
}

  這時我心中有兩點擔心首先friendsTimeline() 帶有一個 scalaSeq 參數(這是我們剛才用過的重復參數特性)其次friendsTimeline() 方法和 Scitter 對象中的 publicTimeline() 方法一樣(如果不信可以運行 javap 查證)返回一個元素列表 scalaList這兩種類型在 Java 代碼中有多好用?

  最簡單的方法是用 Java 代碼而不是 Scala 編寫一組小型的 JUnit 測試所以接下來我們就這樣做雖然可以測試 Scitter 實例的構造並調用它的 verifyCredentials() 方法但這些並不是特別有用 — 記住我們不是要驗證 Scitter 類的正確性而是要看看從 Java 代碼中使用它有多容易為此我們直接編寫一個測試該測試將獲取 friends timeline — 換句話說我們要實例化一個 Scitter 實例並且不使用任何參數來調用它的 friendsTimeline() 方法

  這有點復雜因為需要傳入 scalaSeq 參數 — scalaSeq 是一個 Scala 特性它將映射到底層 JVM 中的一個接口所以不能直接實例化我們可以嘗試典型的 Java null 參數但是這樣做會在運行時拋出異常我們需要的是一個 scalaSeq 類以便從 Java 代碼中輕松地實例化這個類

  最終我們還是在 mutableListBuffer 類型中找到一個這樣的類這正是在 Scitter 實現本身中使用的類型

  清單 現在我明白了自己為什麼喜歡 Scala……

   package comtednewardscittertest;

import orgjunit*;
import comtednewardscitter*;

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 (scitterverifyCredentials())
    {
      scalaList statuses =
        scitterfriendsTimeline(new llectionmutableListBuffer());
      AssertassertTrue(statuseslength() > );
    }
    else
      AssertassertTrue(false);
  }
}

  使用返回的 scalaList 不是問題因為我們可以像對待其他 Collection 類一樣對待它(不過我們的確懷念 Collection 的一些優點因為 List 上基於 Scala 的方法都假設您將從 Scala 中與它們交互)所以遍歷結果並不難只要用上一點 舊式 Java 代碼(大約 年時候的風格)

  清單 重回 又見 Vector……

   package comtednewardscittertest;

import orgjunit*;
import comtednewardscitter*;

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 (scitterverifyCredentials())
    {
      scalaList statuses =
        scitterfriendsTimeline(new llectionmutableListBuffer());
      AssertassertTrue(statuseslength() > );
     
      for (int i=; i<statuseslength(); i++)
      {
        Status stat = (Status)statusesapply(i);
        Systemoutprintln(statuser()screenName() + said + stattext());
      }
    }
    else
      AssertassertTrue(false);
  }
}

  這將我們引向另一個部分即將參數傳遞到 friendsTimeline() 方法不幸的是ListBuffer 類型不是將一個集合作為構造函數參數所以我們必須構造參數列表然後將集合傳遞到方法調用這樣有些單調乏味但還可以承受

  清單 現在可以回到 Scala 嗎?

   package comtednewardscittertest;

import orgjunit*;
import comtednewardscitter*;

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 (scitterverifyCredentials())
    {
      llectionmutableListBuffer params =
        new llectionmutableListBuffer();
      params$plus$eq(new Count());
     
      scalaList statuses = scitterfriendsTimeline(params);

      AssertassertTrue(statuseslength() > );
      AssertassertTrue(statuseslength() == );
     
      for (int i=; i<statuseslength(); i++)
      {
        Status stat = (Status)statusesapply(i);
        Systemoutprintln(statuser()screenName() + said + stattext());
      }
    }
    else
      AssertassertTrue(false);
  }
}

  所以雖然 Java 版本比對應的 Scala 版本要冗長一點但是到目前為止從任何要使用 Scitter 庫的 Java 客戶機中調用該庫仍然非常簡單好極了

  結束語

  顯然對於 Scitter 還有很多事情要做但是它已經逐漸豐滿起來感覺不錯我們設法對 Scitter 庫的通信部分進行了 DRY 化處理並且為 Twitter 提供的很多不同的 API 調用合並了所需的可選參數 — 到目前為止Java 客戶機基本上沒有受到我們公布的 API 的拖累即使 API 沒有 Scala 所使用的那些 API 那樣干淨但是如果 Java 開發人員要使用 Scitter 庫也不需要費太多力氣

  Scitter 庫仍然帶有對象的意味不過我們也開始看到一些有實用意義的 Scala 特性正在出現隨著我們繼續構建這個庫只要有助於使代碼更簡潔更清晰越來越多這樣的特性將添加進來本應如此

  是時候說再見了我要短暫離開一下等我回來時我將為這個庫增加對離線測試的支持並增加更新用戶狀態的功能到那時Scala 迷們請記住功能正常總比功能失常好(對不起我只是太喜歡開玩笑了


From:http://tw.wingwit.com/Article/program/Java/JSP/201311/19590.html
    推薦文章
    Copyright © 2005-2022 電腦知識網 Computer Knowledge   All rights reserved.