HTTP異步編程
1.1 HTTP請求掛起#
Play的設計初衷在於完成較短的請求。通過HTTP接口,Play使用固定的線程池來處理請求隊列。爲了達到理想的效果,線程池應該設計得儘可能小。最典型的情況:以處理程序的數量+1作爲最佳值來設定線程池的大小。這意味着如果某個請求非常耗時(比如處理長時間的運算),它將會阻塞線程池並且影響應用程序的響應能力。當然,可以通過增大線程池大小來解決問題,但是這樣會造成資源的極大浪費,而且線程池的大小也不可能無止盡地增大。
試想聊天應用程序的例子:瀏覽器發送一個阻塞的HTTP請求,這個HTTP請求的作用是等待新消息後顯示。這種類型的HTTP請求會佔用很長時間(通常是好幾秒),從而導致線程池的阻塞。如果有100個用戶同時連接這個聊天應用程序,那麼至少需要提供100個線程。這還是可以接受的,如果有1000個用戶呢?甚至有10000個呢?
爲了解決這種情況,Play允許臨時掛起HTTP請求。掛起的HTTP請求仍然保持連接,但是該請求的執行會被移出線程池並稍後進行嘗試。根據需要,Play可以在一段固定的延時後恢復現場,繼續執行請求。
下例Action使用now()方法調用ReportAsPDFJob,該Job需要較長的處理時間。按照正常的情況,程序必須等待ReportAsPDFJob執行完後,才能向HTTP發送響應結果:
public static void generatePDF(Long reportId) {
Promise<InputStream> pdf = new ReportAsPDFJob(report).now();
InputStream pdfStream = await(pdf);
renderBinary(pdfStream);
}
1.2 Continuations#
如果請求非常耗時,就把正在執行的代碼掛起,並將佔用的線程釋放出來爲其他的請求提供服務。這種高效的解決方案稱爲Continuations。在Play的早期版本中並沒有await()方法,但可以等價地使用waitFor()方法。兩者的效果相同,waitFor()方法也是將Action掛起,並在需要的時候重新調用。
在應用中引入Continuations技術是爲了使代碼的異步處理變得簡單化。由於Continuations允許顯式地掛起和重用代碼,因此可以採用如下方式:
public static void computeSomething() {
Promise<String> delayedResult = veryLongComputation(…);
String result = await(delayedResult);
render(result);
}
public static void loopWithoutBlocking() {
for(int i=0; i<=10; i++) {
Logger.info(i);
await("1s");
}
renderText("Loop finished");
}
Cotinuations主要通過使用控制器調用await()方法實現,該方法接收兩種不同類型的參數(事實上有6種重載的方法,但是主要應用場景爲2種)。
- 第一種:採用timeout的方式來調用await()方法,參數類型可以爲毫秒,或者爲字符串類型的字面表達式(例如1s代表一秒鐘)。
- 第二種:採用Future對象的方式來調用,並且通常情況下會使用Play的Promise(定義在lib.F中,實現了java Future類)。當Promise完成後會返回並繼續執行其餘的處理。值得注意的是,Promise中可以觸發多個事件,比如waitAny()方法參數中包含多個事件,當完成了其中任何一個,該Promise便能夠返回並繼續執行。
因此,上述的兩種方式都會導致在未來某個時間點觸發事件。第一種爲預先指定,而第二種需要根據Promise完成的時間。
Play引入Cotinuations,使得編寫同類事件結構的代碼更加簡單:
//相關處理A
await(timeout or promise);//等待promise執行完畢
//相關處理B
在等待處理的過程中,服務器將HTTP線程釋放出來,因此Play能夠併發處理更多的請求,而且非常高效。當timeout時間到達或者Promise執行完畢,後續的代碼會繼續執行,並且不需要開發者編寫任何與線程喚起相關的方法。
使用timeout的方式來調用await()方法:
public static void useTimeout() {
for(int i=0; i<=10; i++) {
Logger.info(i);
await("1s");
}
renderText("Execute finished");
}
以上這段代碼在執行過程中,一共釋放了10次線程,並且在每秒等待結束後重新喚起。從開發者的角度看,這個處理過程是非常透明的,並且允許直觀地構建應用(而不需要擔心創建非阻塞應用,因爲這些都交由Play進行處理)。
使用Promise的方式來調用await()方法:
public static void usePromise(){
F.Promise<WS.HttpResponse> promise1=WS.url("http://domain1.com").getAsync();
F.Promise<WS.HttpResponse> promise2=WS.url("http://domain2.com").getAsync();
F.Promise<List<WS.HttpResponse>> promises = F.Promise.waitAll(promise1, promise2);
await(promises);
renderText("Execute finished");
}
上述代碼使用了lib.F中的waitAll()方法,需要等待promise1和promise2都處理完成後,才能夠繼續執行後續處理。類似地,Play還提供了waitAny(),waitEither()等方法。
1.3 HTTP流式響應
由於Play提供在非阻塞的情況下輪詢處理請求的功能,讀者可能會有這樣的設想:服務器端能否實現只要生成了一部分可用的結果數據就馬上發送給瀏覽器。在Play中實現這個功能完全沒有問題,而實現的關鍵就是以Content-Type:Chunked作爲HTTP的響應類型。它允許將HTTP響應分成不同的塊(chunk)分批發送,只要這些分塊一被髮出,瀏覽器立馬就能接收到。以下是使用await()方法和Continuations的實現:
public static void generateLargeCSV() {
CSVGenerator generator = new CSVGenerator();
response.contentType = "text/csv";
while(generator.hasMoreData()) {
String someCsvData = await(generator.nextDataChunk());
response.writeChunk(someCsvData);
}
}
1.4 WebSocket介紹
1> WebSocket介紹#
WebSocket的目標是通過在瀏覽器和應用程序之間建立一條通信的頻道,實現兩者的雙向通信。在Play中的WebSocket實現如下:在瀏覽器端,可以使用“ws://” URL方式建立socket連接:
new WebSocket("ws://localhost:9000/helloSocket?name=Guillaume")
WS /helloSocket MyWebSocket.hello
- WebSocket控制器只有request對象,沒有response對象。
- WebSocket控制器可以訪問session,但訪問權限是隻讀的。
- WebSocket控制器沒有renderArgs,routeArgs以及flash作用域。
- WebSocket控制器只能從路由模式或者以查詢字符串的形式來讀取參數。
- WebSocket控制器擁有inbound和outbound兩種通信頻道。
當客戶端(即瀏覽器)通過ws://localhost:9000/helloSocket 建立socket連接時,Play會調用MyWebSocket控制器中的hello Action方法,一旦MyWebSocket.hello方法結束,該socket連接就會自動關閉:
public class MyWebSocket extends WebSocketController {
public static void hello(String name) {
outbound.send("Hello %s!", name);
}
}
當然,大部分情況下並不需要急於將socket連接關閉,可以使用await()方法進行一些適當的擴展。以下程序使用了Continuations,使服務器具有應答功能:
public class MyWebSocket extends WebSocketController {
public static void echo() {
while(inbound.isOpen()) {
WebSocketEvent e = await(inbound.nextEvent());
if(e instanceof WebSocketFrame) {
WebSocketFrame frame = (WebSocketFrame)e;
if(!e.isBinary) {
if(frame.textData.equals("quit")) {
outbound.send("Bye!");
disconnect();
} else {
outbound.send("Echo: %s", frame.textData);
}
}
}
if(e instanceof WebSocketClose) {
Logger.info("Socket closed!");
}
}
}
}
public static void echo() {
while(inbound.isOpen()) {
WebSocketEvent e = await(inbound.nextEvent());
for(String quit: TextFrame.and(Equals("quit")).match(e)) {
outbound.send("Bye!");
disconnect();
}
for(String msg: TextFrame.match(e)) {
outbound.send("Echo: %s", frame.textData);
}
for(WebSocketClose closed: SocketClosed.match(e)) {
Logger.info("Socket closed!");
}
}
}
2> 使用WebSocket#
開發WebSocket應用,需要使用支持WebSocket的瀏覽器(比如Chrome)。Firefox和Opera出於安全考慮,無法使用WebSocket協議。與長時間處理的方法不同,WebSocket中的方法需要在預先定義的時間間隔執行鍼對模型更新的數據庫檢查,從而觸發事件。WebSocket的理念是保持連接狀態,等待事件的觸發,然後將事件廣播給每個需要的用戶。因此在Play中實現WebSocket的最佳方式是使用存儲在服務器端的狀態對象。雖然這樣做有點違背無狀態(stateless)以及RESTful風格的理念,但這應該是使用WebSocket的最佳實踐。在設計WebSocket應用時,開發者需要根據自己的實際情況對代碼做進一步的優化:
下面將演示如何創建WebSocket應用。使用play new命令創建新的應用,名稱爲websocket:
Play new websocket
在app/models/目錄中創建StatefulModel.java:
package models;
import play.libs.F;
public class StatefulModel {
public static StatefulModel instance = new StatefulModel();
public final F.EventStream event = new F.EventStream();
private StatefulModel() { }
}
StatefulModel非常簡單,由以下幾個部分組成:
- 私有的構造方法StatefulModel()。
- 單例的靜態實例化對象(單例模式),這意味着服務器端只允許存在一個StatefulModel的實例。
- EventStream對象,是WebSocket在Play中實現的最核心部分,它允許發佈事件來通知所有等待監聽的用戶。
在這個例子當中,使用了標準的EventStream來訪問當前的事件。Play同時提供了ArchiveEventStream(讀者可以在samples-and-tests目錄中查看Play提供的chat應用示例),可以獲取所有可用的信息。打開app/controllers/Application.java文件,添加如下代碼:
package controllers;
import play.mvc.*;
import models.*;
public class Application extends Controller {
public static void index() {
render();
}
public static class WebSocket extends WebSocketController {
public static void listen() {
while(inbound.isOpen()) {
String event = await(StatefulModel.instance.event.nextEvent());
outbound.send(event);
}
}
}
}
在Application控制器中增加了繼承WebSocketController的靜態類。WebSocketController與標準的控制器有所不同:前者是基於inbound/outbound模式,而後者是面向request/response模式的。上述代碼的業務邏輯非常簡單,只是通過while循環來檢查inbound是否處於打開狀態(即WebSocket處於連接狀態),接着調用nextEvent()方法來等待事件的觸發。
早期的Play版本並沒有await()方法。await()方法的作用是掛起HTTP請求,釋放當前資源讓框架以便繼續處理其他的請求。直到有新的事件添加到StatefulModel中,程序會從之前離開的地方繼續執行,而不是重新開始。
代碼將數據從事件發送到outbound,最終返回到瀏覽器。那麼瀏覽器需要如何處理這些數據呢?創建views/application/index.html模板:
#{extends 'main.html' /}
#{set title:'Home' /}
<div id="socketout"></div>
<script type="text/javascript">
// Create a socket
var socket = new WebSocket('@@{Application.WebSocket.listen}')
// Message received on the socket
socket.onmessage = function(event) {
$('#socketout').append(event.data+"<br />");
}
</script>
在模版中,僅僅使用div來顯示WebSocket發送的數據。JavaScript的內容爲:
- 創建WebSocket連接。
- 將獲得的數據添加到div中。
在爲自定義的WebSocket增加事件之前,先定義好路由:
WS /socket Application.WebSocket.listen
需要注意的是,這裏使用WS作爲HTTP請求類型來描述WebSocket請求,剩下的部分和之前一樣配置。現在WebSocket已經可以運行了,但是這時候開啓應用,打開瀏覽器看到的是空白頁面,這是因爲服務器並沒有返回數據給瀏覽器,所以最後需要做的是觸發事件。創建異步Job,在EventStream中增加一些消息。
在app目錄下新建jobs包,然後創建Startup.java文件:
package job;
import play.jobs.*;
import models.StatefulModel;
@OnApplicationStart(async = true)
public class Startup extends Job {
public void doJob() throws InterruptedException {
int i = 0;
while (true) {
i++;
Thread.sleep(1000);
StatefulModel.instance.event.publish("On step " + i);
}
}
}
這個Job會在應用啓動的時候執行,並一直循環下去,直到應用停止。每次迭代的時候暫停1秒鐘,然後爲StatefulModel的EventStream發送一個事件。
開啓應用,訪問http://localhost:9000/查看效果,當打開頁面的時候,可以發現事件會廣播到每個監聽的瀏覽器: