基于Express实现云课堂远程共享白板(canvas+websocket)

利用canvas搭建白板

首先在html中设置一个白板区域,添加一个清除白板的按钮

<canvas id="whiteboard" width="1280px" height="720px" class="whiteboard-canvas"></canvas>
<div>
    <button id="clear" class="btn-warning">清除画板</button>
</div>

接着在js中创建一个App对象,用于管理所有白板相关的属性和方法,并且添加处理鼠标事件的方法。

App.whiteboard = $('#whiteboard');
App.ctx = App.whiteboard[0].getContext("2d");
// 标记是否开始绘图
App._startedDrawing = false;
// 鼠标事件关联绘图:下笔、移动、提笔
App.whiteboard.on('mousedown mouseup mousemove', null, function(e) {
    // 如果没有开始画画并且事件不是下笔 则不开始画画
    if (!App._startedDrawing && e.type != "mousedown") return;
    App._startedDrawing = true;
    // 获得偏移坐标
    var offset = $(this).offset();
    // 所有需要的数据
    var data = {
        x: (e.pageX - offset.left),
        y: (e.pageY - offset.top),
        type: e.handleObj.type,
        color: App.ctx.strokeStyle,
        imageData: App.whiteboard[0].toDataURL()
    }
    // 呈现在自己的画板上
    App.draw(data);
})
// 鼠标事件关联清除画板
$('#clear').on('click', function() {
    App.clear();
})

然后写draw函数,将传入的data在画布上展现出来;以及clear函数,用于清空画板

// 绘图操作 
App.draw = function (data) {
    var originalColor = App.ctx.strokeStyle;
    App.ctx.strokeStyle = data.color;
    if (data.type == "mousedown") {
        App.ctx.beginPath();
        App.ctx.moveTo(data.x, data.y)
    } else if (data.type == "mouseup") {
        App.ctx.stroke();
        App.ctx.closePath();
        App._startedDrawing = false;
        App.socket.emit('save-data', App.whiteboard[0].toDataURL());
    } else {
        App.ctx.lineTo(data.x, data.y);
        App.ctx.stroke();
    }
    App.ctx.strokeStyle = originalColor;
};
App.clear = function () {
    App.ctx.clearRect(0, 0, App.whiteboard[0].width, App.whiteboard[0].height);
};

这个时候,就完成了鼠标路径到画布显示的路径

利用websocket同步画板

这里在服务端和客户端均是利用socket.io来实现websocket。首先在客户端js中写好连接websocket,发送笔画、接受笔画的方法。

// 建立websocket
App.socket = io('http://' + window.location.host);
// 初始化图片和色彩
App.socket.on('setup', function (color, dataUrl) {
    App.ctx.strokeStyle = color;
    if (dataUrl) {
        // 从url加载图片
        var imageObj = new Image();
        imageObj.onload = function () {
            App.ctx.drawImage(this, 0, 0);
        };
        imageObj.src = dataUrl;
    }
});
// 接受笔画信息
App.socket.on('draw', App.draw);
// 接受清除画板请求
App.socket.on('clear', App.clear);
// 发送笔画至服务端
App.socket.emit('do-the-draw', data);
// 发送画完一笔的信息至服务端
App.socket.emit('save-data', App.whiteboard[0].toDataURL());
// 发送清除画板请求
App.socket.emit('clear');

在客户端,由于socket.io是基于server进行websocket操作的,而server的架设在bin\www文件中,因此为了能够在路由中实现websocket,需要完成一个router.js→app.js→www的过程。

// eoutes\canvas.io
var imageData;
// 对不同用户给不同的颜色
var colors = [ "#CFF09E", "#A8DBA8", "#79BD9A", "#3B8686", "#0B486B" ];
var i = 0;
var IO = null;
// 这里定义了一个io函数
router.io = function(io) {
    io.on('connection', (socket) => {
        if (i == 5) i = 0;
      socket.emit('setup', colors[i++], imageData);
  
      socket.on('do-the-draw', (data) => {
        // 用户画了一笔
        socket.broadcast.emit('draw', data);
        imageData = data.imageData;
      })
  
      socket.on('clear', function() {
        // 用户清除画板
        socket.broadcast.emit('clear');
        imageData = null;
      })
  
      socket.on('save-data', (data) => {
        // 用户画完一笔
        imageData = data;
      })
    });
    IO = io;
    return io;
}

这里在路由中定义了一个对传入io进行socket操作的函数,并且将这个函数传出出去。

// app.js
// 引入路由
var canvasRouter = require('./routes/canvas')
// 引入路由中的io函数
app.canvasIO = canvasRouter.io;
// 便于在路由中响应http请求
app.use('/canvas', canvasRouter);

而app.js获得router传出的这个函数,并再次传出去

// bin\www
// 架设server
var server = http.createServer(app);
// 引入socket.io
var io = require('socket.io')(server);
// 将io传入app.js的canvasIO函数中,最终传入canvas.js的io函数中
app.canvasIO(io);

www获得app.js传出的对io进行操作的函数,并且传入server的io。这个时候,不同客户端之间就可以实时共享白板了。

添加上传画布背景功能

为了便于对某一张图片进行标记和讲解,需要添加一个上传画布背景的功能。

选用的依然是bootstrap-fileinput插件,在html中插入上传框,在上传初始化中将上传的文件格式限制为’jpg’, ‘gif’, ‘png’, ‘jpeg’。上传后,在路由中接受文件并暂存为临时文件。

//上传背景图片
router.post('/upload', function(req, res) {
    var form = new formidable.IncomingForm();
	// ...
        // formidable对象的临时路径等属性的初始化
	form.parse(req, function(err, fields, files) {
        var filepath = '';
		for (var key in files) {
                        // ...
			// 获取到临时存放的文件地址
		}
		var fileExt = filepath.substring(filepath.lastIndexOf("."));
        // 文件类型校验
		if ((".jpg.png.bmp.jpeg").indexOf(fileExt.toLowerCase()) == -1) {
                        // ...
			// 格式不对的操作
		} else {
            // ...
            //  提供格式正确的返回值,并且利用socket向所有客户端发送背景临时文件的地址
        }
    });
})

然后在客户端js中加入更换图片背景的方法

// 接受图片背景
App.socket.on('drawImage', (data) => {
    var imgObj = new Image();
    imgObj.src = '/canvas/image?path=' + data.data; // 通过url获得服务端图像临时文件
    imgObj.onload = function() {
        App.ctx.drawImage(this, 0, 0, 1280, 720); // canvas 绘制图像
    }
    $("#downblock").hide();
    // 或者直接将图像作为背景,但不推荐
    //$('#whiteboard').css("background", "url(" + data.data + ") no-repeat");
})

通过上传图片→分发图片→绘制图片的过程,所有的客户端,不论是已经打开的,还是后续打开的,都可以在画布上看见新的图片。

切换黑白板功能

切换黑白板功能只需要在预先存好黑白两个底色,然后在路由中存储一个是否为白色的变量。每次客户端点击切换黑白板的时候,将请求emit到服务端,然后服务端修改这个变量,并且在emit到所有客户端即可

保存画板功能

保存画板需要用到canvas的一个toDataURL方法,然后将获得的url下载下来

// 保存画板
$(document).ready(() => {
    $('#save').click(() => {
        downLoad(whiteboard.toDataURL("image/png"));
    })
})

function downLoad(url){
    // 创建一个新的元素,将href值设为url,并点击它
}

通过点击新建的下载元素,可以通过canvas的url,将画布转为png文件下载下来

同样地,这个简单的共享白板也可以添加更多优化,例如撤销恢复功能,添加线条、方框、箭头等基本元素的功能,以及对学生访问白板的权限管理等。

脱机运行云课堂:利用树莓派架设局域网

为了在脱机环境下,可以利用树莓派架设一个云课堂,需要开启树莓派的无线热点,并且自动为访问设备分配IP

安装hostapd

sudo apt-get install hostapd
sudo systemctl stop hostapd
sudo nano /etc/hostapd/hostapd.conf
首先需要安装hostapd,并停止服务以调整设置:

ssid:wifi名
hw+mode:a:802.11a(5G),b:802.11b(2.4G),g:802.11g(2.4G),为保证兼容性,一般设置为2.4G
channel:信道编号
wpa_passphrase:wifi密码

接下来是启用新配置
sudo nano /etc/default/hostapd
将 DAEMON_CONF修改为/etc/hostapd/hostapd.conf

最后启动hostapd
sudo systemctl unmask hostapd
sudo systemctl enable hostapd
sudo systemctl start hostapd
这个时候,就已经看得到wifi信号了

设置wlan的静态ip

树莓派作为网关,需要分配一个静态地址

首先更改dhcpch配置
sudo nano /etc/dhcpcd.conf
在末尾修改为静态的ip
interface wlan0
static ip_address=192.168.100.1/24
nohook wpa_supplicant

然后重启 dhcpcd 服务
sudo systemctl restart dhcpcd

安装dnsmasq服务

dnsmasq提供的dhcp服务可以为客户端分配IP

首先安装dnsmasq
sudo apt-get install dnsmasq
sudo systemctl stop dnsmasq

然后修改配置文件
sudo nano /etc/dnsmasq.conf

在最后加上两行:
interface=wlan0
dhcp-range=192.168.100.10,192.168.100.200,255.255.255.0,24h
这样,当客户端连接wifi时,就可以将192.168.100.10到192.168.100.200之间的ip分配给客户端了。

可以看见,给这部设备分配的ip是192.168.100.35

这个时候启动express,便可以使用局域网中的设备打开树莓派上运行的网站了

树莓派局域网登录功能实现:Express+jQuery+MongoDB

序言

为了在树莓派上架设教学系统,这里以Express为框架,MongoDB作为数据库,利用了部分jQuery语法,实现了局域网内的注册、登录和找回密码服务。

前期设计

流程设计

因为是局域网登录,没有使用邮箱验证等联网方式注册账户,而是通过班级的注册码进行验证;同时找回密码的方式是由管理员或教师申请账号安全码。

页面设计

设计上使用的简洁的浮窗式界面,实现上使用了一个html文件,以及两个css文件,分别管理页面的表单样式、其他页面布局样式。二者分离开来,有助于表单样式的复用,将来在其他页面上使用到表单,可以调用同一个css文件。

数据库存储格式

  • MongoDB作为非关系型数据库(NoSQL)的一种,相比于关系型数据库,其格式更加灵活,对简单CRUD操作效率更高。
  • 同时MongoDB基于文档,以优化的二进制json(bson)格式存储数据,支持了更多数据类型,提高了运行效率,更有分片集群等功能,适合高并发海量数据的存储。
  • 在这个例子中,需要进行的操作比较简单,没有事务的需求,因此使用MongoDB作为数据库

学生、教师和管理员的信息分别存在三个集合中,共有的信息基本如下:

学(工)号用户名密码权限等级账号安全码
(字符串)(字符串)(字符串)(整型)(字符串数组)

注册码存在以下集合中:

班级权限注册码
(字符串)(整型)(字符串)

以及存储班级信息的集合

前端脚本

页面切换

脚本第一部分用来在不同的页面之间切换,使用原生的操作方式,利用document.getElementById().style等方法脚本化css,实现对元素的隐藏和展示,达到切换页面的效果

function show_form(value) {
//...
//展示登录框...
}
function to_login() {
	document.getElementById('login-form-box').style.display = 'inline';
    document.getElementById('register-form-box').style.display = 'none';
    document.getElementById('forget-form-box').style.display = 'none';
    document.getElementById('find-form-box').style.display = 'none';
//切换到登录界面
}
function to_register() {
//...
//切换到注册界面
}
function to_forget() {
//...
//切换到找回密码界面
}
function to_find() {
//...
//切换到找回密码成功界面
}

jQ请求和响应

脚本第二部分用来向服务器端发送请求,实现注册、登录、找回密码的功能。这部分使用了jQ语法,利用$.post()函数,大大简化了请求操作

$(document).ready(function() {

    //注册功能
    $("#register-button").click(function() {
    	//...
    	//向后端发送POST请求
    	//发送用户名、密码、注册码和身份(教师、学生)
    	//接受是否注册成功的消息
    });

    //登录功能
    $("#login-button").click(function() {
    	//...
        //向后端发送POST请求
    	//发送用户名、密码和身份(教师、学生)
    	//接受是否登录成功的消息
    }); 

    //找回密码功能
    $('#forget-button').click(function() {
    	//...
        //向后端发送POST请求
    	//发送账户安全码和身份(教师、学生)
    	//接受是否验证成功的消息
        //如果验证成功,跳转到重设密码界面
    });

    //找回成功并设置新密码
    $("#find-button").click(function() {
    	//...
        //向后端发送POST请求
    	//发送账户安全码,新密码和身份(教师、学生)
    	//接受是否重设密码成功的消息
        //如果重设密码成功,跳转到登录界面
});

后端处理

连接MongoDB数据库

首先需要mongodb模块

npm install mongodb --save

然后写一个脚本mongodb.js,整合连接数据库的方法,简化调用

const MongoClient = require("mongodb").MongoClient;//引入模块
const dbname = 'piclass';//数据库名
const url = 'mongodb://localhost:27017/' + dbname;//数据库所在的url

//合并插入单个和多个文档的方法,同时提前写好实际不会发生变化的变量,简化数据库的调用
function insertSome(collection, document, callback) { //只传入集合名,文档和回调函数
    if (!Array.isArray(document)) { 
    	//调用连接Mongodb数据库的方法
        MongoClient.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }, function(err, db) {
            if (err) throw err;
            var dbo = db.db(dbname);
            dbo.collection(collection).insertOne(document, function(err, res) {
                if (err) throw err;
                console.log("插入单个文档成功");
                db.close();
                if (callback) callback(res);
            });
        });
     } else {
     	//...
        //insertMany()方法
     }
}

function find(collection, query, callback) {
	//...
    //简化find方法查找元素
}

function find_select(collection, query, skip, limit, callback) {
	//...
    //包含限制个数、跳过个数的find方法
}

function updateSome(collection, query, update, justOne, callback) {
	//...
    //更新一条或者所有符合要求的文档的某个数据
}

function deleteSome(collection, query, justOne, callback) {
	//...
    //删除符合要求的文档
}

function sort(collection, isAscend, callback) {
	//...
    //文档排序
}

module.exports.insertSome = insertSome;
module.exports.find = find;
module.exports.find_select = find_select;
module.exports.updateSome = updateSome;
module.exports.deleteSome = deleteSome;
module.exports.sort = sort;
module.exports.clear = function(collection, callback) { 
    deleteSome(collection, {}, false); 
    if (callback) callback();
} //清空集合

配置路由

发送至express后台的请求由aqq.js分发到各个路由文件中,这里将登录界面的所有请求都由index.js默认路由来响应。

var express = require('express'); //引入express模块
var db = require('../myscripts/mongodb.js'); //引入操作数据库的自定义模块
var router = express.Router(); //调用路由

/* GET home page. */ //根页面直接使用登录静态页面进行响应
router.get('/', function(req, res, next) {
  res.sendfile( __dirname.slice(0, -6) + "public/" + "login.html");
});

/* Login Page */ // /login也同样使用这个静态页面
router.all('/login', function(req, res, next) {
  res.sendfile( __dirname.slice(0, -6) + "public/" + "login.html");
});

/* User Rsgister */ //响应注册请求
router.post('/process_register', function(req, res, next) {
  //...
  //获得用户名、密码、注册码和身份
  //1、根据身份进入不同的数据库集合
  //2、判断用户名是否已存在
  //3、判断注册码是否有效
  //4、存储用户数据
  //5、传回客户端操作是否成功等信息
})

/* User Login */ //响应登录请求
router.post('/process_login', function(req, res, next) {
  //...
  //同样地,查询用户名和密码是否存在,并且反馈信息
})

/* Forget Password */ //响应找回密码请求
router.post('/process_forget', function(req, res, next) {
  //...
  //查询账号安全码是否有效,并且反馈信息
})

/* Find Password */ //响应重设密码请求
router.post('/process_find', function(req, res, next) {
  //...
  //再次验证安全码,同时将新的密码存入数据库中,并且反馈信息
})

结语

利用这些功能,可以简单搭建一个注册、登录和找回密码功能的web应用。要继续完善,还需要在后面的开发中利用cookie或者session storage等本地存储方案,实现登录状态的保持;增加管理页面,实现高权限账户对低权限账户的管理和注册码、账号安全码的分发;以及对代码进行优化等等。