如何在宝塔上搭建express后端

问题

按照网络上的关于在宝塔上搭建express后端的方法,在我的环境下是有问题的。先简单叙述网上其他博文中的办法:先新建站点,然后上传express后端到站点目录下,然后再pm2管理器中启动,最后在站点的配置文件加上几行:

location / {
	    proxy_pass http://127.0.0.1:3000; // 监听的本地端口
}

然而这样会导致一个情况:只有根目录下的网页可以访问到,于是会导致如图情况:

连页面请求的css、js等文件都请求不到。

解决方案

研究了宝塔端pm2后,终于发现了正确的搭建方式:先在pm2中启动express后端,再在pm2中添加映射。具体的方式如下:

1. 上传写好的express后端

上传到一个方便管理的目录下

2. 在pm2中启动express

填写好文件夹地址、启动文件和项目名,点击添加,pm2可以自动在正确的端口启动express。这里使用了express-generator,所以启动文件是bin/www

3. 添加映射

点击上图中需要添加映射的项目右边的“映射”

填入需要添加映射的站点即可,宝塔会自动创建一个网站,在左侧“网站”面板中可以管理。

这时候所有的资源都请求得到了。

原因猜测

原方法访问不到的原因必然是仅仅加上那几行,Nginx无法正确映射所有的express框架中访问的资源的url。而直接使用pm2的自动配置映射的功能,可以自动完成对Nginx的配置,比较省时。

树莓派云课堂开发总结

完成功能

  1. 网站界面:Bootstrap4+jQuery+Vue.js
  2. 局域网搭建:hostapd+dhcpch+dnsmasq
  3. 局域网登录功能:Express+jQuery+MongoDB
  4. 局域网登录状态保存:express-session+cookie-parser
  5. 文件上传、下载和推送:bootstrap-fileinput+formidable+fs+socket.io
  6. 习题创建、完成和查看统计:MongoDB+Echart
  7. 云端电子白板:canvas+websocket
  8. 课堂直播:obs/ffmpeg+node-media-server+flv.js
  9. 直播弹幕:canvas+websocket

页面展示

更多计划

  1. 完善已有的功能,增加灵活性,增强用户体验
  2. 添加更多功能,使课堂更加方便
  3. 完善权限系统,界定超级管理员,管理员,可创建班级、可管理班级和无特殊权限的教师,学生等角色的权限功能

心得

js已经成为了一门应用场景非常广泛的语言,从服务器、到web客户端、以及窗口程序,都能使用js完成。nodejs的广泛使用,AngularJS、vue.js等新框架的诞生,mvvm、mvw等模式的融入,正在不断使js符合最新的设计理念。electron等打包方案使js在不同领域展现其独特性和适用性。Webpack、gulp等前端工具使js最初作为一项脚本语言而能开发大型项目成为可能。掌握好js及基于js的各种工具,非常有益。

课堂直播弹幕功能

首先利用vue的循环语句来绑定html中的列表元素

<div class="card-body" id="danmaku">
    <li v-for="chat in chats">
        {{ chat.name }}: {{ chat.content }}
    </li>
</div>
new Vue({
    el: '#danmaku',
    data: {
        chats: [
            { name: "学生1", content: "233"},
            { name: "学生2", content: "233"},
            { name: "学生3", content: "233"},
            { name: "学生4", content: "233"}
        ]
    }
})

然后增加弹幕发送框,利用v-model绑定输入框

var msg = new Vue({
        el: '#send',
        data: {
            message: ''
        }
    })

    $('#btn-send').on('click', () => {
        addDanmaku('‘你’', msg.message);
    })

function addDanmaku(name, content) {
    while (chats.length >= 15) {
        chats.shift();
    }
    chats.push({
        name: name,
        content: content
    })
    msg.message = '';
}

接着使用websocket进行所有客户端弹幕的同步

// 客户端js
var socket = io('ws://' + window.location.host);
socket.on('down', function(data) {
        addDanmaku(data.name, data.content);
    })

socket.emit('up', {
    name: name, 
    content: msg.message
});
// 服务端js
// 接收弹幕
socket.on('up', (chat) => {
    socket.broadcast.emit('down', chat);
})

最后添加利用canvas绘制弹幕的功能,学习了一下他人的思路,先对canvas创建一个弹幕对象,设置canvas的属性并创建容器,然后利用setInterval不断更新更新弹幕文本的位置,并重绘canvas。控制绘图的draw函数:

// 绘制弹幕
        this.draw = function () {
            if (this.interval != "") return; // 如果已经有重绘,则返回
            var _this = this; // 传入this(弹幕对象)
            this.interval = setInterval(function () { // 每20毫秒进行一次绘制
                _this.ctx.clearRect(0, 0, _this.width, _this.height); // 先擦除画布
                _this.ctx.save();   // 然后将当前画布保存
                for (var i = 0; i < _this.msgs.length; i++) {
                    if (!(_this.msgs[i] == null)) {
                        if (_this.msgs[i].left==null) { // 新创建的弹幕,不存在作为左坐标left属性
                            _this.msgs[i].left = _this.width; // 新弹幕的位置在最右边
                            _this.msgs[i].top = parseInt(Math.random() * 200) + 30; // 设置弹幕的高度
                            _this.msgs[i].speed = 4; // 设置弹幕的速度
                            _this.msgs[i].color = _this.colorArr[Math.floor(Math.random() * _this.colorArr.length)]; // 设置弹幕的颜色 
                        }else{
                            if(_this.msgs[i].left < -200){
                                _this.msgs[i]=null;  // 弹幕离开屏幕后删除
                            }else {
                                _this.msgs[i].left = parseInt(_this.msgs[i].left - _this.msgs[i].speed); // 弹幕移动
                                _this.ctx.fillStyle = _this.msgs[i].color; // 在画板上设置颜色
                                _this.ctx.fillText(_this.msgs[i].msg, _this.msgs[i].left, _this.msgs[i].top); // 在画板上重绘弹幕
                                _this.ctx.restore(); // 写入画板
                            }
                        }
                    }
                }
            }, 20);
        };

并且同时将创建弹幕的函数调用放在addDanmuku函数中,这样不论是自己发弹幕还是接收弹幕,都可以看见飘过的弹幕

基于Express框架+obs/ffmpeg进行云课堂直播(node-media-server+flv.js)

云课堂中,直播功能非常重要,可以很大程度上弥补单纯web端功能的不足,尤其是串流电脑屏幕时,可以将使用任何教学软件的过程串流给客户端,效果拔群。

推流方式选择

一、Web端推流

Web端推流在现在使用得很少,一般基于rtmp-streamer模块进行,同时需要swf插件。并且现在各大浏览器正在陆续抛弃FlashPlayer,因此不采用这种方式进行推流。

二、利用ffmpeg推流

ffmpeg是非常常用的开源的视频音频处理程序,在很多软件中有使用。如果在js使用,可以利用封装了ffmpeg命令行调用的fluent-ffmpeg模块进行调用。然而ffmpeg在多个平台需要不同的本地客户端,并且配置环境变量,或者临时写入路径,因此主要有两个方法:

  1. 随网站载入,下载ffmpeg执行文件,临时存储。但由于平台多样、ffmpeg程序体积大的问题并不合适。
  2. 在网站上预先提供ffmpeg下载和安装,但由于更新版本、构建等问题,加以该云课堂需要脱离外部网络运行,这个方法的使用也有缺陷。

在章节最后一节,尝试了将ffmpeg打包成用户界面更舒适的应用,用于显示器推流。

另外,直播推流对系统性能的开销很大,出于对优化和实际应用场景等方面考虑,既然需要本地预先安装推流客户端,不如选择更加成熟的软件。

三、利用OBS(Open Broadcaster Software)推流

OBS是一个免费、开源的视频录制、推流软件,其优化力度大、对系统性能占用小、支持复杂灵活的场景,并且具有直接推流至某一服务器的完善功能,因此使用OBS对课堂直播进行推流。

OBS的使用

obs的使用非常灵活,可以捕捉各种各样的来源,包括屏幕捕获、窗口捕获、媒体源、浏览器、图片、文字等,并且可以实时查看当前的捕获情况。

obs的推流需要一个服务器地址,将视频流发送至服务端,因此服务端需要一个服务来提供地址获取视频

服务端监听:node-media-server模块

首先安装模块

sudo npm install node-media-server –save

然后创建一个调用模块的脚本

// myscripts\stream.js
const NodeMediaServer = require('node-media-server');
 
const config = {
  rtmp: {
    port: 1935,
    chunk_size: 60000,
    gop_cache: true,
    ping: 60,
    ping_timeout: 30
  },
  http: {
    port: 8000,
    allow_origin: '*'
  }
};

function run() {
    console.log('开始监听推流');
    var nms = new NodeMediaServer(config);
    nms.run();
}

module.exports.run = run;

因为框架中运行服务器的是www文件,因此把启动对串流的监听也放在www中

// bin\www
// 启动推流监听
var stream = require('../myscripts/stream').run;
stream();

客户端拉流:flv.js

flv.js是一个开源的脚本,可以不借助flash在H5的video标签中播放flv。

<head>
    <script src="https://cdn.bootcss.com/flv.js/1.4.0/flv.min.js"></script>
    <!-- ...... -->
</head>
<body>
    <video id="videoElement" width="1280px" controls></video>
</body>

紧接着对这个vedio添加来源

// stream.js
$(document).ready(() => {
    if (flvjs.isSupported()) {
        var url = 'http://' + document.domain + ':8000/live/test.flv';
        var videoElement = document.getElementById('videoElement');
        var flvPlayer = flvjs.createPlayer({
            type: 'flv',
            url: url
        });
        flvPlayer.attachMediaElement(videoElement);
        flvPlayer.load();
        flvPlayer.play();
    }
});

注意url中的“test”便是直播名,同时在串流时作为串流的秘钥使用

启动直播功能

启动express框架,便能看见后台的消息:

开始监听推流
2020-6-20 0:05:50 13172 [INFO] Node Media Server v2.1.9
2020-6-20 0:05:50 13172 [INFO] Node Media Rtmp Server started on port: 1935
2020-6-20 0:05:50 13172 [INFO] Node Media Http Server started on port: 8000
2020-6-20 0:05:50 13172 [INFO] Node Media WebSocket Server started on port: 8000

对于OBS串流,这个时候需要在obs里填写串流信息。此时在本机上进行测试,域名填写localhost。

服务器:rtmp://localhost/live
串流秘钥:test

在obs中开始串流,便能在网站上查看到直播了。

优化开播设置

直接在课程界面添加服务器和串流秘钥即可

$(document).ready(() => {
    //请求所有需要的数据
    $.get("/users/process_getdata", (data) => {
        var name = data.username;
        var classname = data.classname;
        //修改称呼
        let text = $("#welcome").text();
        $("#welcome").text(text + name);
        let key = "stream-key-" + classname;
        $('#stream-url').val('rtmp://' + document.domain + '/live');
        // 串流服务器url
        $('#stream-key').val(key);
        // 串流秘钥
    }, "json");
})

非常简约

附:利用ffmpeg推流

ffmpeg功能非常强大,同时只需要命令行就可以进行推流。为了提高用户体验,我们将操作打包成一个nodejs应用。

首先为了使用ffmpeg推流,需要一个如下的指令启动ffmpeg,并且将屏幕流推送到服务器:

ffmpeg -f gdigrab -i desktop -vcodec libx264 -preset:v ultrafast -tune:v zerolatency -f flv rtmp://127.0.0.1/live/stream-key-0.flv

当然可以使用js直接执行这个命令。当然也可以使用fluent-ffmpeg模块,它将ffmpeg命令行以js形式转译,便于执行命令,调用ffmpeg的方式如下:

const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = __dirname+"\\..\\libs\\ffmpeg.exe";
var command = null;
function run(outputPath) {
    command = ffmpeg()
    .setFfmpegPath(ffmpegPath)
    .input('desktop')
    .inputFormat('gdigrab')
    .addOptions([
        '-vcodec libx264',
        '-preset ultrafast',
        '-acodec libmp3lame',
        '-pix_fmt yuv422p'
    ])
    .format('flv')
    .output(outputPath, {
        end: true
    })
    .on('start', function (commandLine) {
        console.log('[' + new Date() + '] Vedio is Pushing !');
        console.log('commandLine: ' + commandLine);
    })
    .on('error', function (err, stdout, stderr) {
        console.log('error: ' + err.message);
    })
    .on('end', function () {
        console.log('[' + new Date() + '] Vedio Pushing is Finished !');
    });
    command.run();
}

function stop() {
    command.kill();
}

直接运行脚本里的run函数,是可以成功推流的。然后编写一个简单的html页面:

为了将node程序打包,这里需要使用electron和打包工具asar,并初始化项目

cnpm install electron -g
cnpm install electron-prebuilt -g
cnpm install electron-packager -g
cnpm install electron-builder -g
cnpm install asar -g
npm init

初始化完项目后,只需要在index.html中编写主窗体,然后在js中加上对fluent-ffmpeg的引用,并且可以直接在本地访问ffmpeg.exe。引入非原生模块需要重新构建模块:

cnpm install electron-rebuild –save-dev
.\node_modules.bin\electron-rebuild.cmd

在页面脚本中使用vue,并且调用run和stop两个函数

$(document).ready(() => {
    var data = {
        isStreaming: false,
        btnMessage: '开始串流',
        url: '',
        key: ''
    }

    var buttonApp = new Vue({
        el: "#btn",
        data: data
    })
    
    var inputApp = new Vue({
        el: "#inputs",
        data: data
    })
    
    $('#btn').on('click', () => {
        if (data.isStreaming == false) {
            if (!data.url) {
                alert('请输入服务器!');
            } else if (!data.key) {
                alert('请输入串流秘钥!');
            } else {
                data.isStreaming = true;
                var path;
                if (data.url[data.url.length - 1] != "/") {
                    path = data.url + "/" + data.key + '.flv';
                } else {
                    path = data.url + data.key + '.flv';
                }
                data.btnMessage = "串流中:" + path;
                const ipc = require('electron').ipcRenderer; 
                run(path);
            }
        } else {
            data.isStreaming = false;
            data.btnMessage = "开始串流";
            stop();
        }
    })
})

由于构件electron需要的部分github资源由于网络原因无法下载,这里使用wpf,用类似的逻辑,命令行启动ffmpeg并推流。

可以看见使用ffmpeg也可以正常地推流桌面,但是同样需要下载客户端,相比较于使用obs,这个方法需要大量的功能改进,例如获得摄像头名称并直播摄像头、电脑音频和麦克风音频选择、桌面推流来源选择等。

基于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实现Web端树莓派云课堂习题上传功能

序言

一个在线教育平台,习题上传功能是不可缺少的。这里基于Nodejs的Express框架,接入MongoDB数据库,实现习题的创建,作答和统计,并使用Echart展示数据。

前期设计

流程设计

一个简单的习题功能,包括了教师的习题创建、学生的习题作答和教师的习题统计数据查看的三部分功能。

页面设计

教师课程页面的习题创建和查看入口

教师创建新习题页面,包括选择题和填空题

教师习题数据统计页面,使用柱状图进行数据的展示,并且可以查看具体的学生名单

学生的习题作答界面

前端脚本

习题上传脚本

主要需要动态修改题目数量、填入题目内容与答案、将填入好的习题数据发送至服务端的功能

$(document).ready(() => {
    // 添加选择题
    $("#addChoice").click(function() { // 点击增加选择题
        $("#choice").append('\
        // 选择题的具体HTML代码
        ');

		// jQ动态创建的DOM元素,需要通过父元素指定来绑定事件
        $("#choice").on("click", ".delete", function() { 
            $(this).parent().remove();
        });

    });
});

$(document).ready(() => {
    $("#addCompletion").click(function() { // 点击增加填空题
        $("#completion").append('\
       // 填空题的具体HTML码
        ')
    })

	// 同样绑定移除填空题的事件
    $("#completion").on("click", ".delete", function() {
        $(this).parent().remove();
    });
})

$(document).ready(() => {
    var choice = [];
    var completion = [];
    $("#submit").click(() => {
        $("#choice >li").each(function() {
            // 利用jQ选择题,获得填入的选择题信息
        });

        $("#completion >li").each(function() {
            // 利用jQ选择题,获得填入的填空题信息
        });
        
        // 将习题数据Post发出
        $.post("process_upload", {test: JSON.stringify(test)}, (data) => {
            // ...
        }, "json");
    })
})

习题作答脚本

只需要利用jQ Ajax功能获得题目并展示,以及将作答数据Post回服务端

$(document).ready(() => {
    $.get("/tests?id=" + id, (data) => { // 获得JSON格式的试题数据
        data.choice.forEach((item, index, array) => {
            $("#choice").append('\
           		// 选择题的具体HTML代码
            ')
        });
        data.completion.forEach((item, index, array) => {
            $("#completion").append('\
                // 填空题的具体HTML代码
            ')
        });
    }, "json");
});

$(document).ready(() => {
    $("#submit").click(() => {
        // 利用jQ获得作答的数据,并且Post给服务端
    });
});

习题数据查看脚本

$(document).ready(() => {
    $.get("/tests?id=" + id, (data) => {
        // 同样获得习题数据并展示
    }, "json");
});

// 绘图
function plant(choiceArr, completionArr) {
    choiceArr.forEach((element, index, array) => {
        // 对每道选择题数据进行绘图
    });

    completionArr.forEach((element, index, array) => {
         // 对每道填空题数据进行绘图
    });
}

后端处理

习题数据

每一份习题在数据库中存储的形式:

_idclassnamenamechoicecompletion
习题的唯一id(自动生成)习题发布的班级习题名所有选择题(数列)所有填空题(数列)

每一道选择题的存储形式:

stem A B C D answer answers
题干 A选项内容 B选项内容 C选项内容 D选项内容 标准答案 选择四个选项的学生名单

每一道填空题的存储形式:

stem answer answers
题干标准答案 回答正确学生名单、回答错误学生名单以及错误答案

路由配置

添加了一个tests.js路由文件,用来处理与试题相关的请求。
由于试题数据需要登录才能查看,并且需要分身份发送不同的内容,
几乎所有的响应都包含对是否已在session中登录和对身份是否符合的判断(在以下代码示例中略),
如果不符合,跳转至登录界面或者403界面。

// 直接返回试题原数据
router.get('/', function(req, res) {
    db.find("test", {"_id": ObjectId(req.query.id)}, (data) => {
        // 调用自己编写的调用数据库的函数,获得试题信息并返回
        // 如果身份是学生,只截取
    });
})

// 试题查看页面
router.get('/paper', function(req, res) {
    db.find("test", {"_id": ObjectId(req.query.id)}, (data) => {
        // 判断习题是否存在
        // 习题若存在,如果session的identity是教师,发送教师的页面;如果是学生发送学生的页面
    });
})

// 试题页面
router.get('/add', function(req, res) {
    // 如果session的identity是教师,发送创建试题页面
})

// 上传新试题
router.post('/process_upload', function(req, res) {
   	// 将获得的新习题数据存入数据库
})

// 提交完成的试题
router.post('/process_submit', function(req, res) {
    // 从数据库中取出相应的习题数据,将学生的作答插入数据中,再更新数据库
})

// 读取习题列表
router.get('/list', function(req, res) {
    // 获得所有习题的列表
})

结语

这样便完成了一个基础的习题上传功能,在接下来还需要做功能的添加和优化,例如

  • 增加题型
  • 设置试题允许作答的起止时间、得分分布等
  • 增加教师对学生作答内容的反馈功能
  • 增加教师对习题的删除、锁定、修改等等动能
  • 增加学生对习题的结果回查、排名查看
  • 在从数据库取原作答情况,和更新新作答情况之间给数据库加Pessimistic Lock,以免学生同时提交作答导致数据丢失
  • 模块化从试题数据库取试题所有数据、仅取题目、仅取学生作答情况和得分、仅取某一个学生的所有作答等功能
  • 以及其他功能和优化

express框架实现文件上传、下载及推送(使用Websocket)

文件上传功能往往是web应用非常重要的功能之一,使用express框架可以简单调用模块实现这一点。

文件上传

客户端上传文件:bootstrap-fileinput插件

插件安装

bootstrap-fileinput插件是基于jQuery和bootstrap的一款集合了文件上传功能优化和界面美化的插件,支持bootstrap3.x和4.x。其包含的css和js文件,需要在bootstrap的css和js文件后引入。
官方下载:https://plugins.krajee.com/file-input
另外需要再引入一个汉化js文件:https://github.com/kartik-v/bootstrap-fileinput/tree/master/js/locales

插件使用

在html中,可以将上传栏嵌套在bootstrap的折叠中

<button type="button" class="btn btn-secondary" data-toggle="collapse" data-target="#downblock">上传文件</button>
<div id="downblock" class="collapse">
	<!--上传框-->
	<input type="file" name="txt_file" id="txt_file" multiple class="file-loading" />
</div>

然后需要调用脚本初始化上传功能

$(document).ready(() => { //文档加载完后执行
	//新建上传对象
    $(function () {
        var oFileInput = new FileInput();
        oFileInput.Init("txt_file", "/file/upload");//上传时发送Post请求的地址
    });

    //初始化上传对象
    var FileInput = function () {
        var oFile = new Object();
        oFile.Init = function(ctrlName, uploadUrl) {
        var control = $('#' + ctrlName);
    
        //设置上传框的选项
        control.fileinput({
            language: 'zh', //语言
            uploadUrl: uploadUrl, //地址
            showUpload: true, //是否显示上传按钮
            showCaption: false,//是否显示标题
            browseClass: "btn btn-info", //按钮样式
            //maxFileSize: 0, //上传最大文件大小(kb),0则无上限
            //minFileCount: 0, //同时上传最小文件数
            maxFileCount: 5, //同时上传最大文件数
            enctype: 'multipart/form-data',
            validateInitialCount: true,
            previewFileIcon: "<i class='glyphicon glyphicon-king'></i>",
            msgFilesTooMany: "选择上传的文件数量({n}) 超过允许的最大数值{m}!",
        });
    
        //文件上传完成后触发
        $("#txt_file").on("fileuploaded", function (event, data, previewId, index) {
            window.location.reload(true);
            alert("上传成功!");
        });
    }
        return oFile;
};

});

服务端接受文件:formidable模块

首先在express项目所在文件夹安装好模块

npm install formidable --save

接着在路由中调用formidable模块以及fs模块并接收文件

var express = require('express');
var router = express.Router();
var fs = require("fs");
var formidable = require("formidable");

//上传文件
router.post('/upload', function(req, res) {
    var form = new formidable.IncomingForm();
	form.encoding = 'utf-8';
	form.uploadDir =__dirname.slice(0, -6) + "uploadfiles/tmp"; // 临时文件夹
	form.keepExtensions = true; // 文件扩展名
	
    // 读取登录用户所在的班级,便于分文件夹存储
    var currentClass = req.session.class || '0';

	form.parse(req, function(err, fields, files) {
        var filepath = '';
		for (var key in files) {
			if (files[key].path && filepath == '') {
                filepath = files[key].path;
				break;
			}
		}
		var targetDir = __dirname.slice(0, -6) + "uploadfiles/" + currentClass + "/";
		// 创建存放文件的目录
		if (!fs.existsSync(targetDir)) {
			fs.mkdir(targetDir, {
				recursive: true
			}, (err) => {
				if (err) throw err;
			});
		}
		var fileExt = filepath.substring(filepath.lastIndexOf("."));
		//上传的原文件信息都在field中
        var originName = fields.fileId.slice(fields.fileId.indexOf("_") + 1, fields.fileId.lastIndexOf("."));
        //重新命名文件
        var filename = originName + "-" + getdate() + "-" + new Date().getTime() + fileExt;
        var targetFile = targetDir + filename;
        fs.rename(filepath, targetFile, err => {
            if (err) {
                console.info(err);
                res.json({
                    code: -1,
                    message: "操作失败"
                });
            } else {
                // 上传成功后触发
                var fileUrl = __dirname.slice(0, -6) + 'uploadfiles/' + currentClass + "/" + filename;
                res.json({
                    code: 200,
                    fileUrl: fileUrl
                });
                console.log("UPLOAD 用户 " + req.session.username + " 上传了文件: " + fileUrl);
            }
        });
	});
})

这样便完成了文件从客户端发送到服务端存储至本地的操作

文件列表推送

客户端主动获取文件列表:fs模块

为了减少一次获取的数据量,仅当用户点击“内容发布”时,向服务端发送get请求,利用AJAX显示文件的列表

var FileFull;
//读取文件列表
function read() {
    $.get('/file/list', (data) => {
        $("#filelist").children().remove(); // 移除原有列表
        FileFull = data;  // 存储新的文件列表
        data.forEach((item, index, array) => {
        	// 调节文件名的现显示格式,并且将节点加入表中
            showName = item.slice(0, item.lastIndexOf('-')) + item.substring(item.lastIndexOf('.'))
            $("#filelist").append('<li href="#" value="' + index + '" class="list-group-item list-group-item-action file" οnclick="showFile(this.innerText, this.value)">' + showName + '</li>');
        });
    }, "json")
}
//读取文件列表
router.get('/list', function(req, res) {
    var currentClass = req.session.class || "0";
    fs.readdir(__dirname.slice(0, -6) + "uploadfiles/" + currentClass + "/", (err, files) => {
        res.json(files);
        res.end();
    })
})

服务端只要简单的利用fs模块读取对应文件夹里的文件列表即可

服务端主动推送文件列表:socket.io模块

首先,socket.io基于http,http模块是在bin/www下导入并生成server,因此需要在此文件内引入该模块:

var app = require('../app');
var http = require('http');
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
var server = http.createServer(app);
var io = require('socket.io')(server);
app.io(io);

然后便可以在路由中使用socket.io模块

var IO;
router.io = function(io) {
    io.on('connection', (socket) => {
        socket.on('message', (data) => {
            console.log(data);
        });
    });
    IO = io;
    return io;
}

定义io函数,将路由绑定上使用Websocket发送信息的功能。
接下来,就可以在需要使用Websocket的地方调用io,例如在用户上传了文件之后,给其他用户发送更新文件列表的请求。

socket.broadcast.emit('updateFileList', {
	code: 200,
	data: '请立即更新文件列表'
});

为了避免代码重复,服务端主动发送的内容仅仅是触发客户端,使其再次发送获得最新文件列表的请求。于是,其他用户也能实时看见最新的文件列表。

文件下载、删除等操作

只需要很简单地在客户端发送请求,服务端接收后利用fs模块进行删除,或者利用res.download()发送文件即可。

function downloadFile() {
    let filefullname = FileFull[$("#operateFile").val()];
    window.open('/file/download?fileName=' + filefullname );
}

需要注意的是,如果直接发送get请求,浏览器将不会对返回的文件进行下载,仅仅保留在response里面。为了下载文件,需要另外打开窗口来替代get请求。

树莓派局域网:利用cookie与session的保存登录状态

首先在树莓派上安装两个nodejs模块:

sudo npm install express-session --save
//session控制
sudo npm install cookie-parser --save
//cookie读取操作

Session:一般保存在服务器内存中,也可以保存至硬盘上。用于存储仍在会话中的用户,保证用户在打开同网站下其他页面时,不用重新登陆。大小基本没有限制。由于储存在服务端,安全性比较高。

Cookie:保存在用户硬盘上。用于重新打开该网站时,从客户端发送给服务端进行验证信息,达到免登录步骤的效果。大小限制根据浏览器不同,在4095~4097字节不等。因为存储在服务端,安全性较低,需要利用进行签名的方法,利用hash值的手段防止篡改。

设置中间件

紧接着,在app.js中为cookie读取和session的存贮设置中间件

app.use(cookieParser('mobilePiClass')); //自定义签名
//登录信息是需要加密的,如果传递不加密cookie,使用不带参数的
//app.use(cookieParser());
app.use(session({
  secret: 'mobilePiClass',
  resave: false,
  saveUninitialized: true
}));
//同样也要为session加密

中间件一般有以下几个特点:

  • 封装了处理某个事件的函数
  • 实现的功能具有通用性
  • 搭建了从require到文件的桥梁

登陆成功后发送cookie

发送cookie只需要很简单的res.cookie()函数:

res.cookie("user", {  
            "username": name, 
            "identity": identity
          }, {
            path: '/',
            signed: true,
            httpOnly: true,
            maxAge: 30 * 24 * 60 * 60 * 1000 
          });

第一个参数是cookie的名称,以字符串传递;
第二个参数是cookie的值,既可以是值,也可以是对象。如果是对象,取cookie的时候取出来的也是对象。
第三个参数是其他设置,其中path是允许访问cookie的路径,signed是是否签名,如果设置cookie中间件时有传入参数,那么才可以设置为true

读取cookie并保存session

if (req.session.username && req.session.identity) {
    //如果已存在session,要做的事(进入页面等)
  } else {
    let cookieInfo = req.signedCookies["user"];
    if (cookieInfo) {
     //如果session不存在,cookie已存在,要做的事
     //例如先将cookie的信息存入session中,在进入页面
    } else {
      //如果session和cookie均不存在,要做的事
      //提示访问非法、跳转登录界面等s
    }
  }

利用类似的逻辑,封装成函数,便可以嵌入路由中,在登录界面以及需要登录才能进入的界面前快速判断用户是否已经处于登录状态。

退出登录

router.get('/process_logout', function(req, res) {
  req.session.destroy(function(err) {
    if (err) {
      console.log("用户退出失败!")
      return;
    }
    res.clearCookie("user");
    console.log("用户成功退出");
    res.end("成功退出");
  });
});

先删除回话,然后删除cookie,接着在静态页面的js脚本中回调刷新页面或者回到登录页面的方法,便可以清除登录数据、重新登录了。

树莓派局域网登录功能实现: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等本地存储方案,实现登录状态的保持;增加管理页面,实现高权限账户对低权限账户的管理和注册码、账号安全码的分发;以及对代码进行优化等等。