前面我们介绍了一个简单的单机画板的实现,现在我们将它向多人画板进行扩展,一个很自然的想法便是将绘制过程封装成指令,然后通过网络发送出去,接收到指定的客户端,需要依照绘图指令,同步进行绘制操作。那么首先需要解决的问题是,如何发送?发送什么?
这里需要解决的是所有人可以同步进行绘制,那么就需要连续不断的的接收和发送数据,所以网络协议我们选择WebSocket,我也见过使用WebRTC协议来实现的,不过这个东西我只是耳闻,从来没有使用过。选择协议的目的是为了全双工的工作,应该HTTP是半双工的协议,所以在这里就不考了。
我们再来思考以下,需要发送什么?这需要我们了解单机画板绘制过程中,需要哪些信息,然后将其抽取出来,在网络上进行传输。还记得嘛,我们对一个绘制路径的分析:一个moveTo方法,加上一系列连续的lineTo方法。
因此我们需要的信息是在哪一个点,使用什么颜色、什么大小的笔,沿着什么样的路径进行绘制。
所以我们就可以抽取出我们需要的信息了:
// json对象
let data = {
type: 0, // 0 表示 moveTo 1表示lineTo
x: 0,
y: 0,
color: "#000000",
size: 1
}
注:点的类型是为了区分,当前的点是执行moveTo方法,还是执行lineTo方法。
这里我们需要一个WebSocket后端,用来分发接收到的所有绘制指令。这里其实是不限定语言的,任何语言的后端都是可以。后端的功能很简单,它只是负责对接收到的数据进行转发给所有客户端即可。主要还是前端对于绘图逻辑的控制。现在我们先不去考虑后端的实现,我们来思考一下,前端绘图的步骤:
当用户按下鼠标时,此时画笔会移动到鼠标点击除,然后用户移动鼠标,此时会途径多个点,画笔依次绘制这些点。所以逻辑就是当用户按下鼠标时,开始执行一个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>选择画笔大小: <span id="size">1pxspan>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() // 将数据写入所有客户端
}