建立WebSocket服务并实现通话
想要就建立WebSocket的视频推流,第一步就是需要搭建WebSocket推流服务器,在Java生态已经有很多成熟的WebSocket服务器方案,这里选用Java-WebSocket进行基地进行开发
引入依赖
本次以maven作为项目构建基础,需要引入Java-WebSocket依赖
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.6.0</version>
</dependency>
关于Java-WebSocket服务器与客户端的构建可以看:[[Kotlin中的WebSocket通信]]
开发思路
在我最初的构想中,WebSocket作为应该有服务端和客户端,服务端负责接收发送端的图像流并转发给接收端,而客户端有两个:发送端与接收端。
实体类设计
为了存储各个连接上来的客户端信息,需要先设计好一个实体类
class SocketClient(
// 客户端名称
var name: String,
// send/receive
var type: String,
// 接收端名称/发送端名称
var direction: String,
// socket链接服务
var socket: WebSocket
)
里面包含:
name:客户端的名称type:类型(发送or接收)direction:接收端/发送端名称socket:以及socket链接对象
同时为了使各个客户端启动时能确定好客户端的名称,类型和目标发送信息,我们需要将这些配置在配置文件中,所以,需要准备一个config.yml
# 服务端配置
server:
port: 8333
# 发送端配置
send:
uri: localhost:8333
name: sendClient
direction: receiveClient
# 接收端配置
receive:
uri: localhost:8333
name: receiveClient
direction: sendClient
构建服务端
import com.project.video.server.entity.SocketClient
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.lang.Exception
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.util.concurrent.CopyOnWriteArrayList
class VideoSocketServer(port: Int) : WebSocketServer(InetSocketAddress(port)) {
companion object {
// 准备两个集合,接收与发送方
val CONN_LIST = CopyOnWriteArrayList<SocketClient>()
}
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
// 获取连接头内容
val name = handshake.getFieldValue("name")
val type = handshake.getFieldValue("type")
val direction = handshake.getFieldValue("direction")
CONN_LIST.add(SocketClient(name, type, direction, conn))
println("新客户端连接:${conn.remoteSocketAddress},name=$name,type=$type,direction=$direction")
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
println("客户端断开连接:${conn.remoteSocketAddress},code=$code,reason=$reason")
CONN_LIST.removeIf { it.socket == conn }
}
override fun onMessage(conn: WebSocket, message: String) {
if (message == "request") {
conn.send("connect successful!")
}
}
override fun onMessage(conn: WebSocket, message: ByteBuffer) {
// 判断消息是否来自发送方
val send = CONN_LIST.stream().filter { it.socket == conn && it.type == "send" }.findFirst().orElse(null)
// 如果发送方已注册到集合中,获取其接收链接并向其发送消息
if (send != null) {
val receive =
CONN_LIST.stream().filter { it.direction == send.name && it.type == "receive" }.findFirst().orElse(null)
receive?.socket?.send(message)
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
ex.printStackTrace()
}
override fun onStart() {
println("WebSocket 服务器已启动")
}
}
分析上面的代码:
CONN_LIST集合用于存放连接上来的客户端地址- 在客户端连接到这个服务端之后,就会触发
onOpen()方法,此时获取完必要信息之后,将构建好的SocketClient存放到CONN_LIST中 - 客户端断开连接之后,会触发
onClost()方法,此时再将客户端从连接中移除 - 当接收端来自客户端的消息之后,会根据消息类型进入
onMessage()方法,在onMessage()方法中,会判断消息的来源,并根据其目标方向,从CONN_LIST中获取指定的目标客户端,并调用目标客户端的send()方法,向指定客户端发送消息
接下来创建服务端主运行程序
import com.project.video.server.handler.VideoSocketServer
class Server {
companion object{
@JvmStatic
fun main(args: Array<String>) {
val videoSocketServer = VideoSocketServer(8333)
videoSocketServer.start()
}
}
}
构建发送客户端
class VideoSendClient(uri: URI, header: HashMap<String, String>, private val grabber: OpenCVFrameGrabber) : WebSocketClient(uri, header) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onOpen(handshake: ServerHandshake) {
println("客户端链接成功,开始发送视频画面....")
send("request")
scope.launch {
VideoCatch.sendVideoStream(grabber, this@VideoSendClient)
}
}
override fun onMessage(message: String) {}
// 处理流
override fun onMessage(bytes: ByteBuffer) {}
override fun onClose(code: Int, reason: String, remote: Boolean) {
println("客户端链接关闭")
}
override fun onError(e: Exception) {
}}
发送客户端在初始化时,需要传入OpenCVFrameGrabber,也就是一个画面采集器,从而采集摄像头画面并将其转换为JPG格式的图像,并发送到服务端。
而val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())则是使用协程让画面持续采集与发送。
当客户端连接服务器成功时,会触发onOpen()方法,此时协程内部开始持续调用画面采集并发送。VideoCatch类的sendVideoStream()方法会持续将数据转发到服务端中
接下来,看一下VideoCatch类是如何设计的
import javafx.application.Platform
import javafx.embed.swing.SwingFXUtils
import javafx.scene.image.ImageView
import kotlinx.coroutines.*
import org.bytedeco.javacv.*
import org.java_websocket.WebSocket
import java.io.*
import javax.imageio.ImageIO
class VideoCatch {
companion object {
// 帧转换器
private val converter = Java2DFrameConverter()
// 运行状态标记
private var isRunning = false
suspend fun sendVideoStream(grabber: OpenCVFrameGrabber, webSocket: WebSocket) {
isRunning = true
// 这里进行文件解码与发送
while (isRunning && webSocket.isOpen) {
// 开始抓取帧(原始帧)
val originFrame = grabber.grab()
val bufferedImage = converter.convert(originFrame)
val byteArrayOutputStream = ByteArrayOutputStream()
withContext(Dispatchers.IO) {
ImageIO.write(bufferedImage, "jpg", byteArrayOutputStream) // 转换为 JPEG val imageData = byteArrayOutputStream.toByteArray()
if (webSocket.isOpen){
webSocket.send(imageData)
}
// 关闭原始帧
originFrame.close()
}
delay(33)
}
}
suspend fun displayVideo(grabber: OpenCVFrameGrabber, imageView: ImageView) = withContext(Dispatchers.Default) {
isRunning = true
while (isRunning) {
// 开始抓取帧(原始帧)
val originFrame = grabber.grab()
// 将OpenCV的视频返回帧处理成Java Swing可以处理的BufferedImage
val bufferedImage = converter.convert(originFrame)
// 渲染到窗口
Platform.runLater {
imageView.image = SwingFXUtils.toFXImage(bufferedImage, null)
}
// 释放原始视频帧
originFrame.close()
}
}
}
}
上面的逻辑主要分为采集发送和本地展示两中
sendVideoStream用于采集画面,并发送到WebSocket服务端,服务端在采集到画面之后会将画面转换为JPG图片发送。
而displayVideo用于本地画面渲染,展示到本地。
具体可以参考本第二篇[https://www.cnblogs.com/nan1mono/p/18766708]
最后,准备服务端的启动类
import com.project.video.client.send.core.VideoCatch
import com.project.video.client.config.VideoInitializer
import com.project.video.client.send.socket.VideoSendClient
import com.project.video.toolkit.ReadYamlUtils
import javafx.application.Application
import javafx.scene.image.ImageView
import javafx.stage.Stage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.bytedeco.javacv.OpenCVFrameGrabber
import java.net.URI
class SendClient : Application() {
private var imageView: ImageView = ImageView()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var grabber: OpenCVFrameGrabber = VideoInitializer.initCatchGrabber()
private lateinit var sendClient: VideoSendClient
private val sendConfig = ReadYamlUtils.readConfigProperty("send") as Map<*, *>
private val socketUrl = sendConfig["uri"].toString()
// 初始化连接头,用以表示信息
private val header = HashMap<String, String>().also {
it["name"] = sendConfig["name"].toString()
it["type"] = "send"
it["direction"] = sendConfig["direction"].toString()
}
override fun start(stage: Stage) {
VideoInitializer.initDisplay(imageView, stage)
stage.show()
// 启动摄像头采集
grabber.start()
// 开启socket链接
sendClient = VideoSendClient(URI("ws://$socketUrl"), header, grabber)
sendClient.connect()
// 实时渲染当前摄像头画面
scope.launch {
VideoCatch.displayVideo(grabber, imageView)
}
}
}
fun main() {
Application.launch(SendClient::class.java)
}
构建接收客户端
import javafx.application.Platform
import javafx.scene.image.Image
import javafx.scene.image.ImageView
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import java.io.ByteArrayInputStream
import java.net.URI
import java.nio.ByteBuffer
class VideoReceiveClient(uri: URI, header: HashMap<String, String>, private val imageView: ImageView) :
WebSocketClient(uri, header) {
override fun onOpen(handshake: ServerHandshake) {
println("客户端链接成功,开始渲染视频画面....")
}
override fun onMessage(message: String) {
println("客户端接收到消息:$message")
}
// 处理流
override fun onMessage(bytes: ByteBuffer) {
val inputStream = ByteArrayInputStream(bytes.array())
Platform.runLater {
imageView.image = Image(inputStream)
}
}
override fun onClose(code: Int, reason: String, remote: Boolean) {
println("客户端链接关闭")
}
override fun onError(e: Exception) {
}
}
接收端的构建比较简单,主要就是将画面接收并渲染到响应的JavaFX窗口。
准备接收端的启动类
import com.project.video.client.receive.socket.VideoReceiveClient
import com.project.video.client.config.VideoInitializer
import com.project.video.toolkit.ReadYamlUtils
import javafx.application.Application
import javafx.application.Platform
import javafx.scene.image.ImageView
import javafx.stage.Stage
import java.net.URI
class ReceiveClient : Application() {
private var imageView = ImageView()
private val sendConfig = ReadYamlUtils.readConfigProperty("receive") as Map<*, *>
private val socketUrl = sendConfig["uri"].toString()
// 初始化连接头,用以表示信息
private val header = HashMap<String, String>().also {
it["name"] = sendConfig["name"].toString()
it["type"] = "receive"
it["direction"] = sendConfig["direction"].toString()
}
private var client = VideoReceiveClient(URI("ws://$socketUrl"), header, imageView)
override fun start(stage: Stage) {
VideoInitializer.initDisplay(imageView, stage)
stage.show()
client.connect()
}
override fun stop() {
client.close()
Platform.exit()
}
}
fun main() {
Application.launch(ReceiveClient::class.java)
}
至此,一个最简单的基于WebSocket的视频推流服务就完成了
[!NOTE] 几个问题
Q:WebSocket对象如何确保send()方法的发送目标?
A:WebSocket的send()目标发送对象是服务端还是客户端本身,取决于在服务端调用还是客户端调用。在服务端中,存储一个发送端的WebSocket对象,调用send()方法是发送到发送端,而在发送端本身调用send()方法则是发送到服务端Q:
kotlin中的this@VideoSendClient和this,以及java中的this有何不同?
A:java中this永远代指当前实例,而kotlin中的this也有同样的功能。kotlin中,this如果在类中,则表示当前实例,在扩展函数或者lambda表达式中,this可能指向不同上下文。而这里的this@VideoSendClient则是消除了作用域,特指了VideoSendClient这个对象,否则会指向CoroutineScope
优化
在上述的客户端中,接收和发送客户端是拆分开的。这不符合使用逻辑。接下来进行优化:将客户端进行整合,接受的同时还能发送
我们改造服务端,代码如下:
import com.project.video.client.core.VideoCatch
import com.project.video.client.config.VideoInitializer
import com.project.video.client.socket.VideoSendClient
import com.project.video.toolkit.ReadYamlUtils
import javafx.application.Application
import javafx.scene.image.ImageView
import javafx.scene.layout.HBox
import javafx.stage.Stage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.bytedeco.javacv.OpenCVFrameGrabber
import java.net.URI
class Client : Application() {
private val sendImageView = ImageView().apply {
fitWidth = 640.0
fitHeight = 480.0
isPreserveRatio = true
}
private val receiveImageView = ImageView().apply {
fitWidth = 640.0
fitHeight = 480.0
isPreserveRatio = true
}
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var grabber: OpenCVFrameGrabber = VideoInitializer.initCatchGrabber()
private lateinit var sendClient: VideoSendClient
private val sendConfig = ReadYamlUtils.readConfigProperty("client") as Map<*, *>
private val socketUrl = sendConfig["uri"].toString()
// 初始化连接头,用以表示信息
private val header = HashMap<String, String>().also {
it["name"] = sendConfig["name"].toString()
it["direction"] = sendConfig["direction"].toString()
}
override fun start(stage: Stage) {
val hBox = HBox(10.0,sendImageView, receiveImageView)
VideoInitializer.initDisplay(hBox, stage)
stage.show()
// 启动摄像头采集
grabber.start()
// 开启socket链接
sendClient = VideoSendClient(URI("ws://$socketUrl"), header, grabber, receiveImageView)
sendClient.connect()
// 实时渲染当前摄像头画面
scope.launch {
VideoCatch.displayVideo(grabber, sendImageView)
}
}
}
fun main() {
Application.launch(Client::class.java)
}
上面的代码中,通过HBox创建了两个渲染画面,一个是本地摄像头画面,另一个则是接收画面。
同时调整Client客户端的逻辑,在发送的同时还需要接口
import com.project.video.client.core.VideoCatch
import javafx.application.Platform
import javafx.scene.image.Image
import javafx.scene.image.ImageView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.bytedeco.javacv.OpenCVFrameGrabber
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import java.io.ByteArrayInputStream
import java.net.URI
import java.nio.ByteBuffer
class VideoSendClient(
uri: URI,
header: HashMap<String, String>,
private val grabber: OpenCVFrameGrabber,
private val receiveImage: ImageView
) : WebSocketClient(uri, header) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onOpen(handshake: ServerHandshake) {
println("客户端链接成功,开始发送视频画面....")
send("request")
scope.launch {
VideoCatch.sendVideoStream(grabber, this@VideoSendClient)
}
}
override fun onMessage(message: String) {
}
// 处理流
override fun onMessage(bytes: ByteBuffer) {
val inputStream = ByteArrayInputStream(bytes.array())
Platform.runLater {
receiveImage.image = Image(inputStream)
}
}
override fun onClose(code: Int, reason: String, remote: Boolean) {
println("客户端链接关闭")
}
override fun onError(e: Exception) {
}
}
在改造后的onMessage()方法中,获取的到的远程画面会渲染到新的窗口JavaFX窗口。
同时还需要修改config.yml的配置
# 服务端配置
server:
port: 3389
# 发送端配置
client:
uri: localhost:8333
name: clientA
direction: client
改造完成之后,不再需要两个客户端配置了,一个客户端即可完成收发
同时服务端的轻松也做了一点微调
import com.project.video.server.handler.VideoSocketServer
import com.project.video.toolkit.ReadYamlUtils
class Server {
companion object{
private val serverConfig = ReadYamlUtils.readConfig()["server"] as Map<*, *>
private val serverPort = serverConfig["port"] as Int
@JvmStatic
fun main(args: Array<String>) {
val videoSocketServer = VideoSocketServer(serverPort)
videoSocketServer.start()
}
}
}
到这一步,双向通信的改造完成

浙公网安备 33010602011771号