【三 異步HTTP編程】 4. WebSockets 原

WebSockets 是瀏覽器上的全雙工通信協議。在WebSockets通道存在期間,客戶端和服務器之間可以自由通信。

現代 HTML5 兼容的瀏覽器可以通過 JavaScript API 原生地支持WebSockets。除了瀏覽器之外,還有許多WebSockets客戶端庫可用於服務器之間、原生的移動APP通信等場景。在這些環境使用WebSockets的好處是可以重用Play服務器現有的TCP端口。

提示:到這裏查看支持WebSockets的瀏覽器相關問題。

處理WebSockets

到目前爲止,我們都是用 Action 來處理標準 HTTP 請求並返回標準 HTTP 響應。但是標準的 Action 並不能處理 WebSockets 這種完全不同的請求。

Play 的 WebSockets 功能建立在Akka stream的基礎上,將收到的 WebSockets 消息變成流,然後從流中產生響應併發送到客戶端。

從概念上來說,一個 “流” 指收到消息、處理消息、最後產生消息這樣一種消息轉換。這裏的輸入和輸出可以完全解耦開來。Akka提供了 Flow.fromSinkAndSource 構造函數來處理這種場景,事實上處理WebSockets時,輸入和輸出並不直接相互連接。

Play在 WebSocket 類中提供了構造WebSockets的工廠方法。

使用 Akka Streams 及 actors

爲了使用 actor 來處理WebSockets,我們使用Play提供的ActorFlow工具來將ActorRef轉換爲流。當Play接收到一個WebSockets連接時,會創建一個actor,它接受一個 ActorRef => akka.actor.Props 函數爲參數並返回一個socket:

import play.api.mvc._
import play.api.libs.streams.ActorFlow
import javax.inject.Inject
import akka.actor.ActorSystem
import akka.stream.Materializer

class Application @Inject()(cc:ControllerComponents)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {

  def socket = WebSocket.accept[String, String] { request =>
    ActorFlow.actorRef { out =>
      MyWebSocketActor.props(out)
    }
  }
}

注意ActorFlow.actorRef(...) 可以用 Flow[In, Out, _] 替換,但是使用actor是最直觀的方式。

這個例子中我們發送的actor類似這樣:

import akka.actor._

object MyWebSocketActor {
    def props(out: ActorRef) = Props(new MyWebSocketActor(out))
}

class MyWebSocketActor(out: ActorRef) extends Actor {
    def receive = {
        case msg: String =>
            out ! ("I received your message: " + msg)
    }
}

從客戶端接收到的所有消息都會被髮往actor,而 Play 提供給actor的所有消息都會被髮往客戶端。上邊的代碼中,actor僅僅將收到的消息加上 “I received your message: ” 前綴然後發回去。

檢測WebSocket何時關閉

當WebSocket關閉時,Play將自動停止actor。就是說你可以通過實現actor的postStop方法來做一些清理工作,如清理WebSocket用到的資源。如:

override def postStop() = {
    someResource.close()
}

關閉WebSocket

在actor停止時,Play也將自動關閉其處理的WebSocket。因此要手動關閉WebSocket,可以主動向actor發送PoisonPill:

impoort akka.actor.PoisonPill

self ! PoisonPill

拒絕WebSocket

某些時候我們需要拒絕一個WebSocket請求,如:連接前需要先對用戶鑑權,或者請求了不存在的資源。Play提供了 acceptOrResult方法來應對這種情況,你可以直接返回一個Result(如 FORBIDDEN、NOT FOUND 等),也可以返回一個處理WebSocket的actor:

import play.api.mvc._
import play.api.libs.streams.ActorFlow
import javax.inject.Inject
import akka.actor.ActorSystem
import akka.stream.Materializer

class Application @Inject()(cc: ControllerComponents)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
    
    def socket = WebSocket.acceptOrResult[String, String] { request =>
        Future.successful(request.session.get("user") match {
            case None => Left(Forbidden)
            case Some(_) => Right(ActorFlow.actorRef {
                MyWebSOcketActor.props(out)
            })
        })
    }
}

注意:WebSocket協議並未實現同源策略,因此無法防禦跨站點WebSocket劫持。要保護websocket不被劫持,需要根據server的origin來檢測request的Origin頭,然後手動來進行鑑權(包括CSRF token)。如果一個WebSocket沒有通過安全性檢查,可以直接用acceptOrResult方法返回FORBIDDEN。

處理不同類型的消息

現在我們只處理了String類型的數據。其實Play也內置了 Array[Byte] 的handler,而且可以從String類型的數據幀中解析出JsValue。數據類型可以在WebSocket的創建方法中以類型參數形式來定義:

import play.api.libs.json._
import play.api.mvc._
import play.api.libs.streams.ActorFlow
import javax.inject.Inject
import akka.actor.ActorSystem
import akka.stream.Materializer

class Application @Inject()(cc:ControllerComponents)
                           (implicit system: ActorSystem, mat: Materializer)
  extends AbstractController(cc) {

  def socket = WebSocket.accept[JsValue, JsValue] { request =>
    ActorFlow.actorRef { out =>
      MyWebSocketActor.props(out)
    }
  }
}

你可能注意到了上邊的兩個JsValue類型,它允許我們處理不同類型的輸入及輸出。在高層級的數據幀類型上尤其有用。

舉個栗子,比如我們希望收到JSON數據類型,並將輸入的消息轉爲InEvent對象,然後將輸出消息格式化爲OutEvent對象。首先需要創建JSON來格式化我們的InEvent及OutEvent:

import play.api.libs.json._

implicit val inEventFormat = Json.format[InEvent]
implicit val outEventFormat = Json.format[OutEvent]

然後可以爲這些類型來創建WebSocket MessageFlowTransformer:

import play.api.mvc.WebSocket.MessageFlowTransformer

implicit val messageFlowTransformer = MessageFlowTransformer.jsonMessageFlowTransformer[InEvent, OutEvent]

最後在WebSocket中使用它們:

import play.api.mvc._

import play.api.libs.streams.ActorFlow
import javax.inject.Inject
import akka.actor.ActorSystem
import akka.stream.Materializer

class Application @Inject()(cc:ControllerComponents)
                           (implicit system: ActorSystem, mat: Materializer)
  extends AbstractController(cc) {

  def socket = WebSocket.accept[InEvent, OutEvent] { request =>
    ActorFlow.actorRef { out =>
      MyWebSocketActor.props(out)
    }
  }
}

現在我們的actor可以直接受到InEvent類型的消息,然後直接發送 OutEvent。

使用Akka streams直接處理WebSockets

Actors抽象並不是總是適合你的場景,特別是如果WebSockets本身表現得更像流的時候。

import play.api.mvc._
import akka.stream.scaladsl._

def socket = WebSocket.accept[String, String] { request =>

  // Log events to the console
  val in = Sink.foreach[String](println)

  // Send a single 'Hello!' message and then leave the socket open
  val out = Source.single("Hello!").concat(Source.maybe)

  Flow.fromSinkAndSource(in, out)
}

一個WebSocket可以訪問初始化WebSocket連接的原始HTTP頭,允許你檢索標準頭以及session數據。但是它不能訪問請求體及HTTP響應。

在這個例子中,我們創建了一個簡單的 sink 來打印消息到控制檯。並創建了一個簡單的 source 來發送簡單的 “Hello!”。我們還需要維持一個永遠不會發送任何內容的 source,否則我們的單個source將終止流,從而終止掉連接。

提示:你可以在 https://www.websocket.org/echo.html 上測試WebSockets。只需要將 location 設置爲: ws://localhsot:9000。

下面是一個丟棄輸入數據,並簡單返回 “Hello!”的例子:

import play.api.mvc._
import akka.stream.scaladsl._

def socket = WebSocket.accept[String, String] { request =>

  // Just ignore the input
  val in = Sink.ignore

  // Send a single 'Hello!' message and close
  val out = Source.single("Hello!")

  Flow.fromSinkAndSource(in, out)
}

下面是另一個例子,將輸入簡單記錄到標準輸出,然後使用發送回client:

import play.api.mvc._
import akka.stream.scaladsl._

def socket =  WebSocket.accept[String, String] { request =>

  // log the message to stdout and send response back to client
  Flow[String].map { msg =>
    println(msg)
    "I received your message: " + msg
  }
}

設置WebSocket幀長度

你可以使用play.server.websocket.frame.maxLength或者設置 --Dwebsocket.frame.maxLength系統變量來設置WebSocket數據幀的長度。舉例如下:

sbt -Dwebsocket.frame.maxLength=64k run

你可以根據項目需要自由的調整適合的幀長度。同事使用較長的數據幀也可以減少DOS攻擊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章