多人共享协作画板——多人画板

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

多人画板技术探究

如何发送?

这里需要解决的是所有人可以同步进行绘制,那么就需要连续不断的的接收和发送数据,所以网络协议我们选择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()        // 将数据写入所有客户端
}

  • 0
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值