§WebSockets
WebSockets 是基于允许双向全双工通信的协议的,可以在 Web 浏览器中使用的套接字。只要服务器和客户端之间存在活动的 WebSocket 连接,客户端就可以随时发送消息,服务器也可以随时接收消息。
现代符合 HTML5 标准的 Web 浏览器通过 JavaScript WebSocket API 原生支持 WebSockets。但是,WebSockets 不仅限于 Web 浏览器使用,还有许多可用的 WebSocket 客户端库,例如允许服务器相互通信,以及原生移动应用程序使用 WebSockets。在这些情况下使用 WebSockets 的优点是可以重用 Play 服务器使用的现有 TCP 端口。
提示:查看 caniuse.com 以了解有关哪些浏览器支持 WebSockets、已知问题以及更多信息的更多信息。
§处理 WebSockets
到目前为止,我们一直在使用 Action
实例来处理标准 HTTP 请求并发送回标准 HTTP 响应。WebSockets 是完全不同的东西,无法通过标准 Action
处理。
Play 的 WebSocket 处理机制是围绕 Pekko 流构建的。WebSocket 被建模为一个 Flow
,传入的 WebSocket 消息被馈送到流中,流产生的消息被发送到客户端。
请注意,虽然从概念上讲,流通常被视为接收消息、对其进行一些处理,然后产生处理后的消息的东西 - 但没有理由必须如此,流的输入可能与流的输出完全断开连接。Pekko 流提供了一个构造函数,Flow.fromSinkAndSource
,专门用于此目的,并且通常在处理 WebSockets 时,输入和输出将完全断开连接。
Play 在 WebSocket 中提供了一些用于构建 WebSockets 的工厂方法。
§使用 Pekko 流和 Actor 处理 WebSockets
为了使用 Actor 处理 WebSocket,我们可以使用 Play 的一个实用程序,ActorFlow 将 ActorRef
转换为流。这个实用程序接受一个函数,该函数将 ActorRef
转换为发送消息到 pekko.actor.Props
对象,该对象描述了 Play 在接收到 WebSocket 连接时应该创建的 Actor。
import javax.inject.Inject
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.stream.Materializer
import play.api.libs.streams.ActorFlow
import play.api.mvc._
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(...)
可以被任何 Pekko 流 Flow[In, Out, _]
替换,但 Actor 通常是最直接的方法。
在这种情况下,我们发送到这里的 Actor 看起来像这样
import org.apache.pekko.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 只会将从客户端接收到的每条消息都加上 我收到了你的消息:
并发送回去。
§检测 WebSocket 何时关闭
当 WebSocket 关闭时,Play 会自动停止 Actor。这意味着你可以通过实现 Actor 的 postStop
方法来处理这种情况,以清理 WebSocket 可能消耗的任何资源。例如
override def postStop() = {
someResource.close()
}
§关闭 WebSocket
当处理 WebSocket 的 Actor 终止时,Play 会自动关闭 WebSocket。因此,要关闭 WebSocket,请向你自己的 Actor 发送 PoisonPill
import org.apache.pekko.actor.PoisonPill
self ! PoisonPill
§拒绝 WebSocket
有时你可能希望拒绝 WebSocket 请求,例如,如果用户必须经过身份验证才能连接到 WebSocket,或者如果 WebSocket 与某些资源相关联,其 ID 传递在路径中,但没有该 ID 的资源存在。Play 提供 acceptOrResult
来解决这个问题,允许你返回一个结果(例如,禁止或未找到),或者使用 Actor 来处理 WebSocket
import javax.inject.Inject
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.stream.Materializer
import play.api.libs.streams.ActorFlow
import play.api.mvc._
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 { out => MyWebSocketActor.props(out) })
})
}
}
}
注意:WebSocket 协议没有实现 同源策略,因此不能防止 跨站点 WebSocket 劫持。为了保护 WebSocket 免受劫持,必须检查请求中的
Origin
标头是否与服务器的来源一致,并且应该实现手动身份验证(包括 CSRF 令牌)。如果 WebSocket 请求未通过安全检查,则acceptOrResult
应该通过返回禁止结果来拒绝请求。
§处理不同类型的消息
到目前为止,我们只看到了处理String
帧。Play 还内置了处理Array[Byte]
帧和从String
帧解析的JsValue
消息的处理程序。您可以将这些作为类型参数传递给 WebSocket 创建方法,例如
import javax.inject.Inject
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.stream.Materializer
import play.api.libs.json._
import play.api.libs.streams.ActorFlow
import play.api.mvc._
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) }
}
}
您可能已经注意到有两个类型参数,这允许我们处理传入消息和传出消息的不同类型。这通常对较低级别的帧类型没有用,但如果您将消息解析为更高级别的类型,则可能有用。
例如,假设我们想要接收 JSON 消息,并且我们想要将传入消息解析为InEvent
,并将传出消息格式化为OutEvent
。我们要做的第一件事是为我们的InEvent
和OutEvent
类型创建 JSON 格式
import play.api.libs.json._
implicit val inEventFormat: Format[InEvent] = Json.format[InEvent]
implicit val outEventFormat: Format[OutEvent] = Json.format[OutEvent]
现在我们可以为这些类型创建一个 WebSocket MessageFlowTransformer
import play.api.mvc.WebSocket.MessageFlowTransformer
implicit val messageFlowTransformer: MessageFlowTransformer[InEvent, OutEvent] =
MessageFlowTransformer.jsonMessageFlowTransformer[InEvent, OutEvent]
最后,我们可以在我们的 WebSocket 中使用这些
import javax.inject.Inject
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.stream.Materializer
import play.api.libs.streams.ActorFlow
import play.api.mvc._
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
的消息。
§直接使用 Pekko 流处理 WebSocket
actor 并不总是处理 WebSocket 的正确抽象,尤其是在 WebSocket 的行为更像流时。
import org.apache.pekko.stream.scaladsl._
import play.api.mvc._
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 请求),允许您检索标准头和会话数据。但是,它无法访问请求主体,也无法访问 HTTP 响应。
在这个例子中,我们创建了一个简单的接收器,它将每条消息打印到控制台。为了发送消息,我们创建了一个简单的源,它将发送一个单一的Hello!消息。我们还需要连接一个永远不会发送任何内容的源,否则我们的单个源将终止流,从而终止连接。
提示:您可以在 https://www.websocket.org/echo.html 上测试 WebSocket。只需将位置设置为
ws://localhost:9000
。
让我们编写另一个示例,它丢弃输入数据并在发送Hello!消息后立即关闭套接字
import org.apache.pekko.stream.scaladsl._
import play.api.mvc._
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)
}
这是一个使用映射流将输入数据记录到标准输出并发送回客户端的另一个示例。
import org.apache.pekko.stream.scaladsl._
import play.api.mvc._
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
要发送数据或访问 WebSocket,您需要在路由文件中添加 WebSocket 的路由。例如
GET /ws controllers.Application.socket
§配置 WebSocket 帧长度
您可以使用 play.server.websocket.frame.maxLength
配置 WebSocket 数据帧 的最大长度,或者在运行应用程序时传递 -Dwebsocket.frame.maxLength
系统属性。例如
sbt -Dwebsocket.frame.maxLength=64k run
此配置让您更好地控制 WebSocket 帧长度,可以根据应用程序需求进行调整。它还可以减少使用长数据帧的拒绝服务攻击。
§配置保持活动帧
首先,如果客户端向 Play 后端服务器发送 ping
帧,它会自动用 pong
帧进行回复。这是 RFC 6455 第 5.5.2 节 的要求,因此在 Play 中是硬编码的,您无需设置或配置任何内容。
相关地,请注意,当使用 Web 浏览器作为客户端时,它们不会定期发送 ping
帧,也不支持 JavaScript API 来执行此操作(只有 Firefox 有一个 network.websocket.timeout.ping.request
配置,可以在 about:config
中手动设置,但这并没有真正帮助)。
默认情况下,Play 后端服务器不会定期向客户端发送 ping
帧。这意味着,如果服务器和客户端都没有定期发送 ping 或 pong,则 Play 会在达到 play.server.http[s].idleTimeout
后关闭空闲的 WebSocket 连接。
为了避免这种情况,您可以让 Play 后端服务器在达到空闲超时(在服务器没有从客户端听到任何消息的情况下)后 ping 客户端。
play.server.websocket.periodic-keep-alive-max-idle = 10 seconds
Play 然后会向客户端发送一个空的 ping
帧。通常这意味着一个活动的客户端会用一个 pong
帧进行回复,您可以根据需要在应用程序中处理该帧。
除了使用双向 ping/pong 保持活动心跳(通过发送 ping
帧),您还可以让 Play 发送一个空的 pong
帧来进行单向 pong 保持活动心跳,这意味着客户端不应该回复。
play.server.websocket.periodic-keep-alive-mode = "pong"
注意:请注意,如果您只在
application.conf
中设置这些配置,它们不会在开发模式(使用sbt run
时)被拾取。因为这些是后端服务器配置,您必须通过PlayKeys.devSettings
在build.sbt
中设置它们,才能使它们在开发模式下生效。有关原因和方法的更多详细信息,请参见 此处。
为了在开发中测试这些保持活动帧,我们建议使用 Wireshark 进行监控(例如,使用 (http or websocket)
之类的显示过滤器)以及 websocat 向服务器发送帧,例如使用
# Add --ping-interval 5 if you want to ping the server every 5 seconds
websocat -vv --close-status-code 1000 --close-reason "bye bye" ws://127.0.0.1:9000/websocket
如果客户端向您的 Play 应用程序发送的关闭状态码不是默认的 1000,请确保他们使用根据 RFC 6455 第 7.4.1 节 定义的有效状态码,以避免任何问题。例如,Web 浏览器通常在尝试使用此类状态码时会抛出异常,而某些服务器实现(例如 Netty)在收到此类状态码时会抛出异常(并关闭连接)。
注意: pekko-http 特定的配置
pekko.http.server.websocket.periodic-keep-alive-max-idle
和pekko.http.server.websocket.periodic-keep-alive-mode
不会影响 Play。为了与后端服务器无关,Play 使用自己的低级 WebSocket 实现,因此它自己处理帧。
下一步: Twirl 模板引擎
发现此文档中的错误?此页面的源代码可以在 此处 找到。在阅读 文档指南 后,请随时贡献拉取请求。有任何问题或建议要分享?前往 我们的社区论坛 与社区开始对话。