多人共享协作画板——多人画板_多人画板同步编辑-程序员宅基地

技术标签: canvas  websocket  前端  网络编程  javascript  

前面我们介绍了一个简单的单机画板的实现,现在我们将它向多人画板进行扩展,一个很自然的想法便是将绘制过程封装成指令,然后通过网络发送出去,接收到指定的客户端,需要依照绘图指令,同步进行绘制操作。那么首先需要解决的问题是,如何发送?发送什么?

多人画板技术探究

如何发送?

这里需要解决的是所有人可以同步进行绘制,那么就需要连续不断的的接收和发送数据,所以网络协议我们选择WebSocket,我也见过使用WebRTC协议来实现的,不过这个东西我只是耳闻,从来没有使用过。选择协议的目的是为了全双工的工作,应该HTTP是半双工的协议,所以在这里就不考了。

发送什么?

我们再来思考以下,需要发送什么?这需要我们了解单机画板绘制过程中,需要哪些信息,然后将其抽取出来,在网络上进行传输。还记得嘛,我们对一个绘制路径的分析:一个moveTo方法,加上一系列连续的lineTo方法。
因此我们需要的信息是在哪一个点,使用什么颜色、什么大小的笔,沿着什么样的路径进行绘制。
所以我们就可以抽取出我们需要的信息了:

  • 点的类型 type
  • x坐标 x
  • y坐标 y
  • 笔的颜色 color
  • 笔的大小 size
// json对象
let data = {
    
    type: 0, // 0 表示 moveTo 1表示lineTo
    x: 0,
    y: 0,
    color: "#000000",
    size: 1
}

注:点的类型是为了区分,当前的点是执行moveTo方法,还是执行lineTo方法。

实现过程

这里我们需要一个WebSocket后端,用来分发接收到的所有绘制指令。这里其实是不限定语言的,任何语言的后端都是可以。后端的功能很简单,它只是负责对接收到的数据进行转发给所有客户端即可。主要还是前端对于绘图逻辑的控制。现在我们先不去考虑后端的实现,我们来思考一下,前端绘图的步骤:

  1. 用户按下鼠标
  2. 用户移动鼠标
  3. 用户松开鼠标

当用户按下鼠标时,此时画笔会移动到鼠标点击除,然后用户移动鼠标,此时会途径多个点,画笔依次绘制这些点。所以逻辑就是当用户按下鼠标时,开始执行一个moveTo方法,然后是多个lineTo方法,数据的格式按照上面定义的发送即可。那么让我们在上篇博客的基础之上,开始添加逻辑吧!

实现代码

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title></title>
		<style type="text/css">
			* {
      
				margin: 0;
				padding: 0;
			}
			.rg{
      
				float: left;
				width: 400px;
				height: 100px;
				text-align: center;
				border: 1px black solid;
				margin-left:-1px ;
			}
			#cas{
      
				width: 800px;
				height: 600px;
				border: #000000 1px solid;
			}
	
			p{
      
				margin: 5px 0 5px 0;
			}
		</style>
	</head>
	<body>
		<div id="seclect">
			<div class="rg" id="secc">
				<p>选择画笔颜色</p>
				<input type="color" id="cl"/>
			</div>
			<div class="rg" id="secw">
				<p>选择画笔大小:&nbsp;<span id="size">1px</span></p>
				<input type="range" onchange="setLineWidth(this)" value="1" min="1" max="10"/>
			</div>
		</div>
		<div id="cas">
			<canvas id="cs" width="800" height="600"></canvas>
		</div>
		
		<script type="text/javascript">				
			var canvas = document.getElementById("cs");//获取画布
			var context = canvas.getContext("2d");
			
			function setLineWidth(e) {
          // this 指向是就是该元素本身
			    console.log("你点击了画笔:", e);
				console.log(e.value)
				context.lineWidth = e.value;
				document.getElementById("size").innerHTML = e.value + " px";
			}
			
			/* 用户绘制的动作,可以分解为如下操作:
				1.按下鼠标
				2.移动鼠标
				3.松开鼠标
				
			   它们分别对应于鼠标的onmousedown、onmousemove和onmouseup事件。
			   并且上述操作必然是有想后顺序的,因为人的操作必然是几个操作
			   集合中的一种。所以我们需要来限定以下,过滤用户的无效操作,
			   只对按照上诉顺序的操作进行响应。
			*/
			let isDowned = false;  // 是否按下鼠标,默认是false,如果为false,则不响应任何事件。
			
			// 开始添加鼠标事件
			canvas.onmousedown = function(e) {
      
				let x = e.clientX - canvas.offsetLeft;
				let y = e.clientY - canvas.offsetTop;
				isDowned = true;   // 设置isDowned为true,可以响应鼠标移动事件
				console.log("当前鼠标点击的坐标为:(", x + ", " + y + ")");
				context.strokeStyle = document.getElementById("cl").value;   // 设置颜色,大小已经设置完毕了
				context.beginPath();    // 开始一个新的路径
				context.moveTo(x, y);   // 移动画笔到鼠标的点击位置
				
				// 多人协作的逻辑
				let pos = {
      type: 0, x: x, y: y, color: context.strokeStyle, size: context.lineWidth}
				client.send(JSON.stringify(pos))
				
			}
			
			canvas.onmousemove = function(e) {
      
				if (!isDowned) {
      
					return ;
				}
				let x = e.clientX - canvas.offsetLeft;
				let y = e.clientY - canvas.offsetTop;
				console.log("当前鼠标的坐标为:(", x + ", " + y + ")");
				context.lineTo(x, y);    // 移动画笔绘制线条
				context.stroke();
				
				// 多人协作逻辑				
				let pos = {
      type: 1, x: x, y: y, color: context.strokeStyle, size: context.lineWidth}
				client.send(JSON.stringify(pos))
			}
			
			canvas.onmouseup = function(e) {
      
				isDowned = false;
			}
			
			
			/*
				在按下鼠标移动的过程中,如果移出了画布,则无法触发鼠标松开事件,即onmouseup。
				所以需要在鼠标移出画布时,设置isDowned为false。
			*/
			canvas.onmouseout = function(e) {
      
				isDowned = false;
			}
			
		</script>
		
		<script>
		function link () {
      
			client = new WebSocket("ws://192.168.0.118:30985/ws/wedraw");    //连接服务器
			
			client.onopen = function(e){
      
				alert('连接了');
			};
			
			client.onmessage = function (e) {
      
			    let data = e.data
				let pos = JSON.parse(data)
				
				console.log("接受到的消息:" + data)
				
				context.strokeStyle = pos.color   // 设置颜色
				context.lineWidth = pos.size      // 设置线宽
				if (pos.type === 0) {
                   // 如果该点是移动画笔,则移动画笔
				    context.beginPath()           // 开始一个新的路径
					context.moveTo(pos.x, pos.y)
				} else if (pos.type === 1) {
            // 如果该点是画线,就画线
				    context.lineTo(pos.x, pos.y);
					context.stroke();                  // 绘制点
				} else {
      
					console.log("不存在的情况,直接返回")
					return
				}
			}
			
			client.onclose = function(e){
      
				alert("已经与服务器断开连接\r\n当前连接状态:" + this.readyState);
			};
			
			client.onerror = function(e){
      
				alert("WebSocket异常!");
			};
		}
		
		function sendMsg(position){
      
			client.send(position);
		}
		link ()  // 直接建立websocket连接
		</script>
	</body>
	
</html>

测试结果

在这里插入图片描述

总结

我们已经实现了通过网络来进行绘制图形的功能了,是不是很有趣呢?但是这样就结束了吗?问题显然是不可能这么简单的,在下一篇博客,我将介绍一个严重的问题和一个悲伤的故事。

附 后端代码

注:这个后端代码严格来说不是我写的,因为我是刚接触go的后端开发人员。这个代码是我参考网上的一个代码修改的,删除了很多我需要的功能,只保留这个广播分发的功能了。而且,你也可以不使用它。自己使用SpringBoot框架写一个WebSocket后端,只要满足功能就行了。

代码结构图
在这里插入图片描述

message_push.go

package main

import (
	"fmt"
	"net/http"
	"ws/ws"

	"github.com/gin-gonic/gin"
)

func main() {
    

	go ws.WebsocketManager.Start() // 启动websocket管理器的协程,它的主要功能是注册和注销用户。

	// 设置调试模式或者发布模式必须是第一步!
	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()

	// 注册中间件
	r.Use(MiddleWare()) // 这个中间件注册在后面就无法起作用了,必须在前面调用。

	r.GET("/", func(c *gin.Context) {
    
		c.String(http.StatusOK, "Welcome to here!")
	})

	wsGroup := r.Group("/ws")
	{
    
		wsGroup.GET("/wedraw", ws.WebsocketManager.WsClient) // 每一个访问都会调用该路由对应的方法
	}

	bindAddress := ":30985"
	r.Run(bindAddress)
}

func MiddleWare() gin.HandlerFunc {
    
	return func(ctx *gin.Context) {
    
		fmt.Println("调用中间件,请求访问路径为:", ctx.Request.RequestURI)
	}
}

ws.go

package ws

import (
	"log"
	"net/http"
	"strings"
	"sync"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
	uuid "github.com/satori/uuid"
)

// Manager 所有 websocket 信息
type Manager struct {
    
	ClientMap            map[string]*Client
	clientCount          uint
	Lock                 sync.Mutex
	Register, UnRegister chan *Client
	BroadCastMessage     chan *BroadCastMessageData
}

// Client 单个 websocket 信息
type Client struct {
    
	Lock sync.Mutex      // 加一把锁
	Id   string          // 用户标识
	Conn *websocket.Conn // 用户连接
}

// 广播发送数据信息
type BroadCastMessageData struct {
    
	Id      string // 消息的标识符,标识指定用户
	Message []byte
}

// 读信息,从 websocket 连接直接读取数据
func (c *Client) Read(manager *Manager) {
    
	defer func() {
    
		WebsocketManager.UnRegister <- c
		log.Printf("client [%s] disconnect", c.Id)
		if err := c.Conn.Close(); err != nil {
    
			log.Printf("client [%s] disconnect err: %s", c.Id, err)
		}
	}()

	for {
    
		messageType, message, err := c.Conn.ReadMessage()
		if err != nil || messageType == websocket.CloseMessage {
    
			break
		}
		log.Printf("client [%s] receive message: %s", c.Id, string(message))

		// 向广播消息写入数据
		manager.BroadCastMessage <- &BroadCastMessageData{
    Id: c.Id, Message: message}
	}
}

// 向所有客户发送广播数据
func (m *Manager) WriteToAll() {
    
	for {
    
		select {
    
		case data, ok := <-m.BroadCastMessage:
			if !ok {
    
				log.Println("没有取到广播数据。")
			}
			for _, client := range m.ClientMap {
    
				sender, flag := m.ClientMap[data.Id]

				// 绘图数据不会发给自己,如果这里是将绘图数据写给客户端,应该跳过正在绘图的人
				if sender.Id == client.Id {
    
					continue
				}

				if !flag {
    
					log.Println("用户不存在") // 这里应该是存在的,先判断一下
				}

				client.Lock.Lock()
				client.Conn.WriteMessage(websocket.TextMessage, data.Message)
				client.Lock.Unlock()
			}

			log.Println("广播数据:", data.Message)
		}
	}
}

// 启动 websocket 管理器
func (manager *Manager) Start() {
    
	log.Printf("websocket manage start")
	for {
    
		select {
    
		// 注册
		case client := <-manager.Register:
			log.Printf("client [%s] connect", client.Id)
			log.Printf("register client [%s]", client.Id)

			manager.Lock.Lock()
			manager.ClientMap[client.Id] = client
			manager.clientCount += 1
			manager.Lock.Unlock()

		// 注销
		case client := <-manager.UnRegister:
			log.Printf("unregister client [%s]", client.Id)
			manager.Lock.Lock()

			if _, ok := manager.ClientMap[client.Id]; ok {
    
				delete(manager.ClientMap, client.Id)
				manager.clientCount -= 1
			}

			manager.Lock.Unlock()
		}
	}
}

// 注册
func (manager *Manager) RegisterClient(client *Client) {
    
	manager.Register <- client
}

// 注销
func (manager *Manager) UnRegisterClient(client *Client) {
    
	manager.UnRegister <- client
}

// 当前连接个数
func (manager *Manager) LenClient() uint {
    
	return manager.clientCount
}

// 获取 wsManager 管理器信息
func (manager *Manager) Info() map[string]interface{
    } {
    
	managerInfo := make(map[string]interface{
    })
	managerInfo["clientLen"] = manager.LenClient()
	managerInfo["chanRegisterLen"] = len(manager.Register)
	managerInfo["chanUnregisterLen"] = len(manager.UnRegister)
	managerInfo["chanBroadCastMessageLen"] = len(manager.BroadCastMessage)
	return managerInfo
}

// 初始化 wsManager 管理器
var WebsocketManager = Manager{
    
	ClientMap:        make(map[string]*Client),
	Register:         make(chan *Client, 128),
	UnRegister:       make(chan *Client, 128),
	BroadCastMessage: make(chan *BroadCastMessageData, 128),
	clientCount:      0,
}

// gin 处理 websocket handler
func (manager *Manager) WsClient(ctx *gin.Context) {
     // 参数为 ctx *gin.Context 的即为 gin的路由绑定函数
	upGrader := websocket.Upgrader{
    
		// cross origin domain
		CheckOrigin: func(r *http.Request) bool {
    
			return true
		},
		// 处理 Sec-WebSocket-Protocol Header
		Subprotocols: []string{
    ctx.GetHeader("Sec-WebSocket-Protocol")},
	}

	// 生成uuid,作为sessionid
	id := strings.ToUpper(strings.Join(strings.Split(uuid.NewV4().String(), "-"), ""))

	// 设置http头部,添加sessionid
	heq := make(http.Header)
	heq.Set("sessionid", id)

	// 建立一个websocket的连接
	conn, err := upGrader.Upgrade(ctx.Writer, ctx.Request, heq)
	if err != nil {
    
		log.Printf("websocket connect error: %s", id)
		return
	}

	// 创建一个client对象(包装websocket连接)
	client := &Client{
    
		Id:   id,
		Conn: conn,
	}

	manager.RegisterClient(client) // 将client对象添加到管理器中
	go client.Read(manager)        // 从一个客户端读取数据
	go manager.WriteToAll()        // 将数据写入所有客户端
}

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_40734247/article/details/119856308

智能推荐

python编码问题之encode、decode、codecs模块_python中encode在什么模块-程序员宅基地

文章浏览阅读2.1k次。原文链接先说说编解码问题编码转换时,通常需要以unicode作为中间编码,即先将其他编码的字符串解码(decode)成unicode,再从unicode编码(encode)成另一种编码。 Eg:str1.decode('gb2312') #将gb2312编码的字符串转换成unicode编码str2.encode('gb2312') #将unicode编码..._python中encode在什么模块

Java数据流-程序员宅基地

文章浏览阅读949次,点赞21次,收藏15次。本文介绍了Java中的数据输入流(DataInputStream)和数据输出流(DataOutputStream)的使用方法。

ie浏览器无法兼容的问题汇总_ie 浏览器 newdate-程序员宅基地

文章浏览阅读111次。ie无法兼容_ie 浏览器 newdate

想用K8s,还得先会Docker吗?其实完全没必要-程序员宅基地

文章浏览阅读239次。这篇文章把 Docker 和 K8s 的关系给大家做了一个解答,希望还在迟疑自己现有的知识储备能不能直接学 K8s 的,赶紧行动起来,K8s 是典型的入门有点难,后面越用越香。

ADI中文手册获取方法_adi 如何查看数据手册-程序员宅基地

文章浏览阅读561次。ADI中文手册获取方法_adi 如何查看数据手册

React 分页-程序员宅基地

文章浏览阅读1k次,点赞4次,收藏3次。React 获取接口数据实现分页效果以拼多多接口为例实现思路加载前 加载动画加载后 判断有内容的时候 无内容的时候用到的知识点1、动画效果(用在加载前,加载之后就隐藏或关闭,用开关效果即可)2、axios请求3、map渲染页面4、分页插件(antd)代码实现import React, { Component } from 'react';//引入axiosimport axios from 'axios';//引入antd插件import { Pagination }_react 分页

随便推点

关于使用CryPtopp库进行RSA签名与验签的一些说明_cryptopp 签名-程序员宅基地

文章浏览阅读449次,点赞9次,收藏7次。这个变量与验签过程中的SignatureVerificationFilter::PUT_MESSAGE这个宏是对应的,SignatureVerificationFilter::PUT_MESSAGE,如果在签名过程中putMessage设置为true,则在验签过程中需要添加SignatureVerificationFilter::PUT_MESSAGE。项目中使用到了CryPtopp库进行RSA签名与验签,但是在使用过程中反复提示无效的数字签名。否则就会出现文章开头出现的数字签名无效。_cryptopp 签名

新闻稿的写作格式_新闻稿时间应该放在什么位置-程序员宅基地

文章浏览阅读848次。新闻稿是新闻从业者经常使用的一种文体,它的格式与内容都有着一定的规范。本文将从新闻稿的格式和范文两个方面进行介绍,以帮助读者更好地了解新闻稿的写作_新闻稿时间应该放在什么位置

Java中的转换器设计模式_java转换器模式-程序员宅基地

文章浏览阅读1.7k次。Java中的转换器设计模式 在这篇文章中,我们将讨论 Java / J2EE项目中最常用的 Converter Design Pattern。由于Java8 功能不仅提供了相应类型之间的通用双向转换方式,而且还提供了转换相同类型对象集合的常用方法,从而将样板代码减少到绝对最小值。我们使用Java8 功能编写了..._java转换器模式

应用k8s入门-程序员宅基地

文章浏览阅读150次。1,kubectl run创建pods[root@master ~]# kubectl run nginx-deploy --image=nginx:1.14-alpine --port=80 --replicas=1[root@master ~]# kubectl get podsNAME READY STATUS REST...

PAT菜鸡进化史_乙级_1003_1003 pat乙级 最优-程序员宅基地

文章浏览阅读128次。PAT菜鸡进化史_乙级_1003“答案正确”是自动判题系统给出的最令人欢喜的回复。本题属于 PAT 的“答案正确”大派送 —— 只要读入的字符串满足下列条件,系统就输出“答案正确”,否则输出“答案错误”。得到“答案正确”的条件是: 1. 字符串中必须仅有 P、 A、 T这三种字符,不可以包含其它字符; 2. 任意形如 xPATx 的字符串都可以获得“答案正确”,其中 x 或者是空字符串,或..._1003 pat乙级 最优

CH340与Android串口通信_340串口小板 安卓给安卓发指令-程序员宅基地

文章浏览阅读5.6k次。CH340与Android串口通信为何要将CH340的ATD+Eclipse上的安卓工程移植到AndroidStudio移植的具体步骤CH340串口通信驱动函数通信过程中重难点还存在的问题为何要将CH340的ATD+Eclipse上的安卓工程移植到AndroidStudio为了在这个工程基础上进行改动,验证串口的数据和配置串口的参数,我首先在Eclipse上配置了安卓开发环境,注意在配置环境是..._340串口小板 安卓给安卓发指令