建立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:WebSocketsend()目标发送对象是服务端还是客户端本身,取决于在服务端调用还是客户端调用。在服务端中,存储一个发送端的WebSocket对象,调用send()方法是发送到发送端,而在发送端本身调用send()方法则是发送到服务端

Q:kotlin中的this@VideoSendClientthis,以及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()  
        }  
    }  
}

到这一步,双向通信的改造完成

posted @ 2025-03-12 09:26  nan1mono  阅读(171)  评论(0)    收藏  举报