級別: 中級
David Geary
, 總裁, Clarity Training, Inc.
2009 年 6 月 15 日
隨著 2.0 版本的發布,Java?Server Faces (JSF) 現在可以輕松地實現健壯的、Ajax 風格的 Web 應用程序。本文是共三部分的系列文章的開篇,JSF 2.0 專家組成員 David Geary 將展示如何利用 JSF 2 中的新特性。在這期文章中,您將了解到如何使用 JSF 2 流線化開發,您將使用注釋和約定代替 XML 配置,簡化導航,并輕松訪問資源。并且您將看到如何在您的 JSF 應用程序中使用 Groovy。
<!-- START RESERVED FOR FUTURE USE INCLUDE FILES--><!-- include java script once we verify teams wants to use this and it will work on dbcs and cyrillic characters --> <!-- END RESERVED FOR FUTURE USE INCLUDE FILES-->
有關 Web 應用程序框架的最佳發源地,人們一直爭論不休:是象牙塔(由理論家空想而來)還是現實世界。在后一種情況下,框架的誕生經受了實際需求的嚴酷考驗。憑直覺判斷,經受了實際需求的考驗要勝過理論家的空想,并且我認為這種直覺完全經得起更進一步的檢驗。
JSF 1 就是在象牙塔中開發的,因此,它的出現并沒有引起太大的轟動。但是,JSF 做對了一件事情 — 它使市場上出現了大量來自實際開發的創新。早些時候,Facelets 的初次登場成為了 JavaServer Pages (JSP) 的強有力候補。然后出現了 Rich Faces,一個出色的 JSF Ajax 庫;接著是 ICEFaces,將 Ajax 和 JSF 聯合起來的新穎方法;還有 Seam、Spring Faces、Woodstock 組件、JSF Templating,等等。所有這些開源 JSF 項目都是由開發人員根據自己需要的功能構建的。
JSF 2.0 專家組實際上對來自這些開源項目的最佳特性進行了標準化。盡管 JSF 2 規范確實是由一些理論家編寫的,但它也受到了來自實際開發的創新的驅動?;叵肫饋?,專家組的工作其實非常輕松,因為我們正站在巨人的肩膀上,比如 Gavin King (Seam)、Alexandr Smirnov (Rich Faces)、Ted Goddard (ICEFaces) 和 Ken Paulson (JSF Templating)。實際上,所有這些巨人都是 JSF 2 專家組的成員。因此,JSF 2 在許多方面都結合了象牙塔和真實世界的長處。并且它展示了這一點。JSF 2 是對 JSF 1 的重大改進。
本文是共三部分的系列文章的開篇,主要有兩個目標:展示激動人心的 JSF 2 新特性,展示如何最佳地利用這些特性,這樣您就能夠利用 JSF 2 提供的功能。通過演示 JSF 2 的應用并伴隨一些最佳使用技巧,我將闡述前面兩個問題。下面是本文將要介紹的技巧:
-
技巧 1:去除 XML 配置
-
技巧 2:簡化導航
-
技巧 3:使用 Groovy
-
技巧 4:利用資源處理程序
但是,我將首先介紹貫穿整個系列文章的示例應用程序。本文的應用程序源代碼可以
下載
獲得。
基于強制映射的 Web 服務 mashup 示例
圖 1 展示了一個 JSF mashup — 我將它稱為 places 應用程序 — 它使用 Yahoo! Web 服務將地址轉換為地圖,并顯示縮放級別和天氣預報:
圖 1. 從 Yahoo! Web Services 中查看地圖和天氣信息
要創建一個地點,需要填寫地址表單,激活 Go 按鈕,然后應用程序將把地址發送給兩個 Web 服務:Yahoo! Maps 和 Yahoo! Weather。
Map 服務在 Yahoo! 服務器上返回指向地址映射的 11 個地圖 URL,使用不同的縮放級別。Weather 服務返回一些預先組裝的 HTML。圖像 URL 和 HTML 內容都輕松地顯示在一個 JSF 視圖中,這要分別感謝
<h:graphicImage>
和
<h:outputText>
。
places 應用程序使您能夠輸入任意數量的地址。您甚至可以多次使用同一個地址 ,如圖 2 所示,它實際上演示了縮放級別:
圖 2. 縮放級別
應用程序的關鍵點
places 應用程序有 4 個托管 bean(managed bean),如
表 1
所示:
表 1. places 應用程序中的托管 bean
托管 bean 名稱 類 范圍
mapService
|
com.clarity.MapService
|
應用程序
|
weatherService
|
com.clarity.WeatherService
|
應用程序
|
places
|
com.clarity.Places
|
會話
|
place
|
com.clarity.Place
|
請求
|
|
運行 places 應用程序
要運行 places 應用程序,需要訪問
developer.yahoo.com/maps/ajax
,從 Yahoo! 獲得一個應用程序 ID,這樣才能使用 Yahoo! Web 服務。單擊 Yahoo! Maps Web Service 中的
Get an App ID
按鈕。得到 ID 后,在
MapService.java
和
WeatherService.java
中用您的 ID 替換
YOUR_ID_HERE
。
|
|
應用程序在會話范圍內存儲了一組
Place
,如
圖 1
所示,并在請求范圍內維護了一個
Place
。應用程序還分別使用應用程序范圍內的
mapService
和
weatherService
托管 beans 為 Yahoo! 的 map 和 weather Web 服務提供了簡單的 API。
創建地點非常簡單。清單 1 顯示了
圖 1
中的視圖所含的地址表單的代碼:
清單 1. 地址表單
<h:form>
<h:panelGrid columns="2">
#{msgs.streetAddress} <h:inputText value="#{
place
.streetAddress}" size="15"/>
#{msgs.city} <h:inputText value="#{
place
.city}" size="10"/>
#{msgs.state} <h:inputText value="#{
place
.state}" size="2"/>
#{msgs.zip} <h:inputText value="#{
place
.zip}" size="5"/>
<
h:commandButton
value="#{msgs.goButtonText}"
style="font-family:Palatino;font-style:italic"
action="#{place.fetch}"
/>
</h:panelGrid>
</h:form>
|
當用戶激活 Go 按鈕并提交表單后,JSF 將調用按鈕的操作方法:
place.fetch()
。該方法將信息從 Web 服務發送到
Place.addPlace()
,后者創建一個新的
Place
實例,使用傳遞給方法的數據初始化實例,并將其存儲在請求范圍內。
清單 2 展示了
Place.fetch()
:
清單 2.
Place.fetch()
方法
public class Place {
...
private String[] mapUrls
private String weather
...
public String fetch() {
FacesContext fc = FacesContext.getCurrentInstance()
ELResolver elResolver = fc.getApplication().getELResolver()
// Get maps
MapService ms = elResolver.getValue(
fc.getELContext(), null, "mapService")
mapUrls = ms.getMap(streetAddress, city, state)
// Get weather
WeatherService ws = elResolver.getValue(
fc.getELContext(), null, "weatherService")
weather = ws.getWeatherForZip(zip, true)
// Get places
Places places = elResolver.getValue(
fc.getELContext(), null, "places")
// Add new place to places
places.addPlace(streetAddress, city, state, mapUrls, weather)
return null
}
}
|
Place.fetch()
使用 JSF 的變量分解器(resolver)查找
mapService
和
weatherService
托管 bean,并且使用這些托管 bean 從 Yahoo! Web 服務獲得地圖和天氣信息。隨后
fetch()
調用
places.addPlace()
,后者使用地圖和天氣信息以及地址,在請求范圍內創建一個新的
Place
。
注意
fetch()
返回
null
。由于
fetch()
是一個與按鈕有關的操作方法,
null
返回值使得 JSF 重新加載同一個視圖,其中顯示用戶會話中的所有位置,如清單 3 所示:
清單 3. 在視圖中顯示位置
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://java.sun.com/jsf/core"
xmlns:h="http://java.sun.com/jsf/html"
xmlns:ui="http://java.sun.com/jsf/facelets">
<h:form>
<!--
Iterate over the list of places
-->
<ui:repeat value="#{
places.placesList
}" var="
place
">
<div class="placeHeading">
<h:panelGrid columns="1">
<!--
Address at the top
-->
<h:panelGroup>
<div style="padding-left: 5px;">
<i><h:outputText value="#{
place.streetAddress
}"/></i>,
<h:outputText value="#{
place.city
}"/>
<h:outputText value="#{
place.state
}"/>
<hr/>
</div>
</h:panelGroup>
<!--
zoom level prompt and drop down
-->
<h:panelGrid columns="2">
<!--
prompt
-->
<div style="padding-right: 10px;margin-bottom: 10px;font-size:14px">
#{msgs.zoomPrompt}
</div>
<!--
dropdown
-->
<h:selectOneMenu
onchange="submit()
"
value="#{
place.zoomIndex
}"
valueChangeListener="#{
place.zoomChanged
}"
style="font-size:13px;font-family:Palatino">
<f:selectItems value="#{places.zoomLevelItems}"/>
</h:selectOneMenu>
</h:panelGrid>
<!--
The map
-->
<h:graphicImage url="#{
place.mapUrl
}" style="border: thin solid gray"/>
</h:panelGrid>
<!--
The weather
-->
<div class="placeMap">
<div style="margin-top: 10px;width:250px;">
<h:outputText style="font-size: 12px;"
value="#{
place.weather
}"
escape="false"/>
</div>
</div>
</div>
</ui:repeat>
</h:form>
</ui:composition>
|
清單 3
中的代碼使用 Facelets
<ui:repeat>
標記迭代用戶會話中存儲的位置列表。對于每個位置,輸出應當如圖 3 所示:
圖 3. 視圖中顯示的位置
修改縮放級別
zoom 菜單(參見
圖 3
和
清單 3
)有一個
onchange="submit()"
屬性,因此當用戶選擇某個縮放級別時,JavaScript
submit()
函數提交菜單的環繞(surrounding)表單。提交表單后,JSF 調用菜單的相關值修改偵聽器 —
Place.zoomChanged()
方法,如清單 4 所示:
清單 4.
Place.zoomChanged()
public void zoomChanged(ValueChangeEvent e) {
String value = e.getComponent().getValue()
zoomIndex
= (new Integer(value)).intValue()
}
|
Place.zoomChanged()
在一個名為
zoomIndex
的
Place
類的成員變量中存儲縮放級別。由于導航不會受到與服務器通信的影響,JSF 將重新加載頁面,并且地圖使用新的縮放級別進行更新,如下所示:
<h:graphicImage url="
#{place.mapUrl}
..."/>
。當繪制地圖時,JSF 調用
Place.getMapUrl()
,它返回當前縮放級別下的地圖 URL,如清單 5 所示:
清單 5.
Place.getMapUrl()
public String getMapUrl() {
return mapUrls == null ? "" : mapUrls[
zoomIndex
]
}
|
使用少量 Facelets
如 果曾經使用過 JSF 1,那么很可能會注意到本文的 JSF 2 代碼中存在一些細微的差別。首先,我使用了 JSF 2 的新的顯示技術 — Facelets — 而不是 JSP。您將從本系列后續文章中看到,Facelets 提供了許多強大的特性來幫助您實現健壯、靈活和可擴展的用戶界面。但是在前面的代碼清單中,我并沒有過多利用這種功能。然而,Facelets 為 JSF 帶來的眾多微小改進之一便是能夠將 JSF 值表達式直接放入到 XHTML 頁面;例如,在
清單 1
中,我將
#{msgs.city}
等表達式直接放入頁面中。如果使用 JSF 1,則必須將表達式封裝到
<h:outputText>
中,例如
<h:outputText value="#{msgs.city}"/>
。但要注意,出于安全考慮,必須始終將來自用戶輸入的文本進行轉義,例如,在
清單 3
中我使用了
<h:outputText>
,它在默認情況下轉義其文本來顯示位置信息。
從 Facelets 角度來講,還需要注意
清單 3
中的
<ui:composition>
標記。該標記指定清單 3 中的 XHTML 頁面將被包含到其他 XHTML 頁面中。Facelets composition 是 Facelets
templating
的中心組件,類似于流行的 Apache Tiles 框架。在本文的后續文章中,我將討論 Facelets 模板并展示如何根據
Composed Method
Smalltalk 模式構建您的視圖。
目前為止,前面的代碼并沒有使用 Facelets,與 JSF 1 相比沒有出現顯著的變化。現在,我將展示更加大的差異。第一個比較大的差異體現在將要為 JSF 2 應用程序編寫的 XML 配置的數量方面。
技巧 1:去掉 XML 配置
Web 應用程序的 XML 配置始終是個麻煩問題 — 它非常冗長并且容易出現錯誤,因此最好將 XML 配置委托給一個框架,比如通過注釋、約定或特定于領域的語言。作為開發人員,我們應該能夠集中精力實現一些操作,而不是將浪費時間在冗長的 XML 方面。
作為一個典型的例子,清單 6 展示了在使用 JSF 1 的情況下,在 places 應用程序中聲明托管 bean 所需的 20 行 XML 代碼:
清單 6. JSF 1 的托管 bean 聲明
<managed-bean>
<managed-bean-class>com.clarity.MapService</managed-bean-class>
<managed-bean-name>mapService</managed-bean-name>
<managed-bean-scope>application</managed-bean-scope>
</managed-bean>
<managed-bean>
<managed-bean-class>com.clarity.WeatherService</managed-bean-class>
<managed-bean-name>weatherService</managed-bean-name>
<managed-bean-scope>application</managed-bean-scope>
</managed-bean>
<managed-bean>
<managed-bean-class>com.clarity.Places</managed-bean-class>
<managed-bean-name>places</managed-bean-name>
<managed-bean-scope>session</managed-bean-scope>
</managed-bean>
<managed-bean>
<managed-bean-class>com.clarity.Place</managed-bean-class>
<managed-bean-name>place</managed-bean-name>
<managed-bean-scope>request</managed-bean-scope>
</managed-bean>
|
對于 JSF 2,XML 消失了,您將對類使用注釋,如清單 7 所示:
清單 7. JSF 2 的托管 bean 注釋
@ManagedBean(eager=true)
public class MapService {
...
}
@ManagedBean(eager=true)
public class WeatherService {
...
}
@ManagedBean()
@SessionScoped
public class Places {
...
}
@ManagedBean()
@RequestScoped
public class Place {
...
}
|
按照約定,托管 bean 的名稱與類名相同,類名的第一個字母被轉換為小寫。例如,
清單 7
中創建的托管,從上到小依次為:
mapService
、
weatherService
、
places
和
place
。也可以使用
ManagedBean
注釋的
name
屬性顯式地指定一個托管 bean,比如:
@ManagedBean(name = "place")
。
在
清單 7
中,我對
mapService
和
webService
托管 bean 使用
eager
屬性。當
eager
屬性為
true
時,JSF 將在啟動時創建托管 bean 并將其放入應用程序范圍。
也可以使用
@ManagedProperty
注釋設置托管 bean 屬性。
表 2
展示了 JSF 2 托管 bean 注釋的完整列表:
表 2. JSF 2 托管 bean 注釋(
@...Scoped
注釋只對
@ManagedBean
有效)
托管 bean 注釋 描述 屬性
@ManagedBean
|
以托管 bean 的形式注冊一個類實例,然后將其放入到使用其中一個
@...Scoped
注釋指定的范圍內。如果沒有指定任何范圍,JSF 將把此 bean 放入請求范圍,如果沒有指定任何名稱,JSF 將把類名的第一個字母轉換為小寫,形成一個托管 bean 名稱;例如,如果類名為
UserBean
,那么 JSF 將創建一個托管 bean,其名為
userBean
。
eager
和
name
屬性都是可選的。
注釋必須結合使用一個實現零參數構造器的 Java 類。
|
eager
,
name
|
@ManagedProperty
|
為托管 bean 設置一個屬性。注釋必須放在類成員變量的聲明之前。
name
屬性指定特性的名稱,默認情況下為成員變量的名稱。
value
屬性是特性的值,可以是一個字符串,也可以是一個 JSF 表達式,比如
#{...}
。
|
value
,
name
|
@ApplicationScoped
|
在應用程序范圍內存儲托管 bean。
|
|
@SessionScoped
|
在會話范圍內存儲托管 bean。
|
|
@RequestScoped
|
在請求范圍內存儲托管 bean。
|
|
@ViewScoped
|
在視圖范圍內存儲托管 bean。
|
|
@NoneScoped
|
將托管 bean 指定為沒有范圍。無范圍的托管 bean 在被其他 bean 引用時比較有用。
|
|
@CustomScoped
|
在定制范圍內存儲托管 bean。
定制范圍就是指可以由頁面創建者訪問的地圖??梢酝ㄟ^編程的方式控制定制范圍內的 bean 的可視性和生命周期。
value
屬性指向一個地圖。
|
value
|
從 faces-config.xml 中移除托管 bean 聲明將極大地減少 XML,但是在 JSF 2 中,通過使用注釋(如我對托管 bean 所做的一樣)或是約定(比如 JSF 2 的簡化的導航處理),幾乎可以去掉所有的 XML 內容。
技巧 2:簡化導航
在 JSF 1 中,導航使用 XML 指定。比如,要從 login.xhtml 轉到 places.xhtml,可能使用清單 8 所示的導航規則:
清單 8. JSF 1 中的導航配置規則和用例
<navigation-rule>
<navigation-case>
<from-view-id>/pages/login.xhtml</from-view-id>
<outcome>places</outcome>
<to-view-id>/pages/places.xhtml</to-view-id>
</navigation-case>
</navigation-rule>
|
要去除
清單 8
中的 XML,可以利用 JSF 2 的導航約定:JSF 將 .xhtml 添加到按鈕操作的末尾并加載該文件。這意味著不需要使用注釋或其他內容,只需要使用約定就可以完整地避免編寫導航規則的需求。在清單 9 在,按鈕的操作是
places
,因此 JSF 加載 places.xhtml:
清單 9. 通過約定進行導航
<h:commandButton id="loginButton"
value="#{msgs.loginButtonText}"
action="
places
"/>
|
對于
清單 9
來說,不需要任何導航 XML。清單 9 中的按鈕加載 places.xhtml,但是前提是該文件和按鈕所在的文件處于同一個目錄中。如果操作并沒有以斜杠(
/
)開頭,那么 JSF 認為這是一個相對路徑。如果需要更加明確一點,可以指定一個絕對路徑,如清單 10 所示:
清單 10. 使用絕對路徑的導航
<h:commandButton id="loginButton"
value="#{msgs.loginButtonText}"
action="
/pages/places
"/>
|
當用戶激活
清單 10
中的按鈕時,JSF 將加載 /pages/places.xhtml 文件。
默認情況下,JSF 將從一個 XHTML 頁面轉至另一個 XHTML 頁面,但是通過指定
faces-redirect
參數可以重定向,如清單 11 所示:
清單 11. 通過重定向進行導航
<h:commandButton id="loginButton"
value="#{msgs.loginButtonText}"
action="places
?faces-redirect=true
"/>
|
技巧 3:使用 Groovy
Java 技術的最大優勢并不是 Java 語言,而是 Java 虛擬機(JVM)。在 JVM 上運行著強大、新穎和創新的語言,比如 Scala、JRuby 和 Groovy,這使您在編寫代碼時擁有了更多選擇。Groovy 這個名字有些奇怪,但是功能非常強大,融合了 Ruby、Smalltalk 和 Java 語言,它是這些語言中最為流行的一種語言(參見
參考資料
)。
使用 Groovy 的理由有很多。首先,它要比 Java 語言更加簡潔、功能更加強大。還有兩個原因:不使用分號,不需要強制轉換(casting)。
您可能還沒有注意到,在
清單 2
中,
Place
類是使用 Groovy 編寫的。這一點可以通過代碼中沒有使用分號看出來,但是注意下面這行代碼:
MapService ms = elResolver.getValue(...)
。對于 Java 代碼,我必須強制轉換
ElResolver.getValue()
的結果,因為該方法返回類型
Object
。Groovy 可以為我自動完成轉換。
可 以將 Groovy 用于任何使用 Java 代碼編寫的 JSF 工件 — 例如,組件、呈現器、驗證器和轉換器。事實上,這對于 JSF 2 來說并不新鮮 — 因為 Groovy 源文件編譯為 Java 字節碼,您只需使用 Groovy 生成的 .class 文件,就好象它們是由
javac
生成的一樣。當然,Groovy 生成的 .class 文件可以正常工作后,需要了解如何熱部署 Groovy 源代碼,并且對于 Eclipse 用戶,答案非常簡單:下載并安裝 Groovy Eclipse 插件(參見
參考資料
)。Mojarra 是 Sun 的 JSF 實現,從版本 1.2_09 之后提供了對 Groovy 的明確支持(參見
參考資料
)。
技巧 4:利用資源處理程序
JSF 2 提供了定義和訪問資源的標準機制。您將自己的資源放到名為 resources 的頂級目錄下,并使用一些 JSF 2 標記來在視圖中訪問這些資源。例如,圖 4 展示了 places 應用程序的資源:
圖 4. places 應用程序的資源
對資源的惟一需求是它必須位于 resources 目錄或 resources 目錄的子目錄中。可以隨意命名 resources 目錄的子目錄。
在您的視圖代碼中,可以使用兩個 JSF 2 標記訪問資源:
<h:outputScript>
和
<h:outputStylesheet>
。這些標記可以結合用于 JSF 2 的
<h:head>
和
<h:body>
標記,如清單 12 所示:
清單 12. 在 XHTML 中訪問資源
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:ui="http://java.sun.com/jsf/facelets"
xmlns:h="http://java.sun.com/jsf/html">
<
h:head
>
...
</
h:head
>
<
h:body
>
<
h:outputStylesheet
library="css" name="styles.css" target="body"/>
<
h:outputScript
library="javascript" name="util.js" target="head"/>
...
</
h:body
>
</html>
|
<h:outputScript>
和
<h:outputStylesheet>
標記有兩個屬性,分別指定了腳本或樣式表:
library
和
name
。
library
名稱對應于 resources 目錄下的子目錄,這是保存資源的位置。例如,如果在 resources/css/en 目錄中有一個樣式表,那么
library
將為
css/en
。
name
屬性是資源本身的名稱。
可重新定位的資源
開 發人員需要能夠在頁面中指定想要呈現他們的資源的位置。例如,如果將 JavaScript 放在頁面體中,瀏覽器將在加載頁面時執行 JavaScript。另一方面,如果將 JavaScript 放到頁面的頭部,那么 JavaScript 只有在得到調用時才會被執行。由于資源的放置位置會影響它的使用方式,因此需要能夠指定希望在哪些位置顯示資源。
JSF 2 資源是
可重新定位的
,這意味著您可以在頁面中指定希望放置資源的位置。您將使用
target
屬性指定位置;比如,在
清單 12
中,我將 CSS 放到頁面體中,而將 JavaScript 放到頁面頭部中。
有些情況下,需要使用 JSF 表達式語言(EL)訪問資源。比如,清單 13 展示了如何使用
<h:graphicImage>
訪問一個圖像:
清單 13. 使用 JSF 表達式語言訪問資源
<h:graphicImage value="#{
resource['images:cloudy.gif']
}"/>
|
|
清單 13 的非 EL 備選方法
無可否認,清單 13 中的語法不是很直觀。它訪問了一個 JSF 為了存儲資源而創建的地圖,因此很少需要使用這種語法。實際上,可以使用
<h:graphicImage/>
訪問圖像,而不需要使用 EL,比如:
<h:graphicImage library="images" name="cloudy.gif"/>
|
|
在 EL 表達式內訪問資源的語法是
resource['
LIBRARY
:
NAME
']
,其中
LIBRARY
和
NAME
對應于
<h:outputScript>
和
<h:outputStylesheet>
標記的
library
和
name
屬性。
結束語
到目前為止,我僅僅觸及了 JSF 2 特性中最淺顯的內容,包括托管 bean、注釋、簡化導航和資源支持。在本系列隨后的兩篇文章中,我將探討 Facelets、JSF 2 的復合組件以及對 Ajax 的內置支持。
下載
描述 名字 大小 下載方法
源代碼
參考資料
學習
獲得產品和技術
討論
關于作者
|
|
|
David Geary 是一名作家、演講家和顧問,也是
Clarity Training, Inc.
的總裁,他指導開發人員使用 JSF 和 Google Web Toolkit (GWT) 實現 Web 應用程序。他是 JSTL 1.0 和 JSF 1.0/2.0 專家組的成員,與人合作編寫了 Sun 的 Web Developer 認證考試的內容,并且為多個開源項目作出貢獻,包括 Apache Struts 和 Apache Shale。David 的
Graphic Java Swing
一直是有關 Java 的暢銷書籍,而
Core JSF
(與 Cay Horstman 合著)是有關 JSF 的暢銷書。David 經常在各大會議和用戶組發表演講。他從 2003 年開始就一直是 NFJS tour 的固定演講人,并且在 Java University 教授課程,兩次當選為 JavaOne 之星。
|
|