Web编程大作业(四)高清重制+在线测验+热点广播(angular框架,websocket,树莓派)



1.高清重制

前几篇写的代码功能更多的是从技术层面出发,把网站需要用到的业务逻辑打通,但没有怎么考虑网页的外观样式和代码结构之类的问题。同时也是觉得因为代码结构写的太乱,很难在此基础上加更多的功能和样式。

于是就从零开始,手写了整个express框架,配合angular重新写了一个高清重制版。样式方面没有用bootstrap,一方面感觉bootstrap还是需要一些css基础,另一方面用flex手写布局本身就挺方便了,基本能满足所有需求,并且更加灵活,可以知道每个属性对html元素产生的影响。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
大多数技术方面的逻辑其实都已经体现在前两篇博客了,还有一些样式方面的需要的话也可以直接看代码,这里就不多赘述。

说几个重制过程中感觉比较有体会的点和踩过的坑吧。

1. express的再理解

在express的文档中有这样一句话
在这里插入图片描述
所谓中间件,指的就是用app.use所调用的部件,一些express内置的,一些是自定义的。理解中间件在我看来其实只要把它们当成一种处理http请求的方式即可。只要这个http请求的地址满足app.use的第一个参数(若缺省则默认为’/‘),那么就会调用第二个参数的中间件。

从之前经常用的路由器说起:

router.get('/',function(req,res,next){
    res.send('hello~');
})

这段代码很好理解,只要在该路由下收到get请求,就会给页面返回’hello~’的响应。可以说function(req,res,next)这个函数就是一个中间件,是一个在收到请求后“被调用”的组件。

那么完全可以用这个思路去理解app.use,区别在于app.use不仅限于get请求而能够接受任何类型的http请求。同时,express.Router()本身就可以在主程序app.js被当作一个中间件来使用:(express.Router()是一个内置的路由中间件,本质上是帮助我们根据不同的路径进行代码分离)

var indexRouter = require('./router/index.js');
app.use(indexRouter);//第一个参数的缺省值为'/'

一些中间件仅仅是为了引入某个功能,用文档中的话说,这种中间件不是函数调用,而是“使能”的意思,比如使后端能够解析req.body:

//用来解析req.body,分别对应application/json和application/x-www-form-unlencoded
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

还有一些第三方中间件可以有各种其他的功能,比如:

//打印和记录请求信息
app.use(logger('dev'));
//“使能”通过req.cookies获取cookie
app.use(cookieParser());

还有一些则负责对http请求返回响应,通常是我们自定义的一些中间件,为了代码整洁清晰,通常把这些中间件放进路由器里,再通过路由中间件express.Router()执行。

值得一提的是,http请求除了对网址的直接访问,还包括了html元素中src属性的间接访问。对主页的一次get请求可能包含了非常多的请求,包括html引入的css样式、script代码、各种图片资源等等,比如我访问了一次首页,实际上发生了9次get请求:
在这里插入图片描述
对于每一个get请求,无论是直接还是间接的,都会经过设置的中间件。为了让网页能正常找到这些静态资源,还需要用express.static()这个中间件设置静态资源的路径(可以理解为重定向)。

app.use(express.static(path.join(__dirname+'/public')));
app.use('/angular',express.static(path.join(__dirname+'/node_modules/angular')));
app.use('/jquery',express.static(path.join(__dirname+'/node_modules/jquery')));

以上就是我对中间件的一点理解。。其实理解中间件也就等同于理解express框架本身。在手写express的时候经常会发现以前原本没有遇到过的问题(比如说不能用req.body获取post请求数据,不能用req.cookies获取当前网页cookie之类的问题),折腾半天之后发现原来是没有引入对应的中间件导致的。所以这个过程其实非常能加深对于中间件作用的理解,也能明白每一个中间件到底对应了什么功能,这些功能如何产生作用,继而明白为什么要写这个中间件。在此基础上再去文档里看各种自定义中间件的request、response的属性和方法也会变得容易很多。

reference:https://www.expressjs.com.cn/

2. angular作用域问题、自动同步问题

这是我课堂页面的导航栏
在这里插入图片描述
利用angular,在写课堂页面时我想到了一个看上去还比较优雅的写法,可以实现页面切换,同时保证各个页面的数据都能实时更新。

<div ng-include="'html/chat.html'" class='container' ng-show="chatIsShow"></div>
<div ng-include="'html/file.html'" class='container' ng-show="fileIsShow"></div>
<div ng-include="'html/problem.html'" class='container_problem' ng-show="problemIsShow"></div>

也就是通过ng-include引入三张网页,同时置于class页面中。使用ng-show控制页面的可见性,并确保同一时间只有一张页面显示,另外两张页面均隐藏。这样一来,即使我当前显示的是“课堂测验”页面,“实时消息”的页面仍然存在,其中的内容依然能正常地发生改变。当用户切换过去即可看到这段时间的实时消息了。

然而遇到了一个问题,在ng-include内部,通过ng-model绑定的变量在class中无法正常被捕获。

比如说chat.html的一个input元素是这样的

<input class='message_input' ng-model="chatMessage" type='text' placeholder="请输入消息">

通常\\$scope.chatMessage即可获取这个输入框里的内容,但是现在不行了。后来查了一下,捕获不到的原因是因为外面套了一个ng-include。在angular中,ng-include(包括ng-repeat,ng-switch,ng-view,ng-controller)实际上会创建一个新的作用域,而这个新作用域就脱离了class中\\$scope的作用范围。解决方法是在chatMessage前加上\\$parent,因为这个新作用域是继承自父级作用域的,可以用\\$parent调用父scope(\\$parent是子作用域指向父作用域的一个引用),也就是把input的内容绑定在class的作用域上。

<!-- $parent解决子页面作用域问题,否则数据不能实时更新 -->
<input class='message_input' ng-model="$parent.chatMessage" type='text' placeholder="请输入消息">

同时还有一个小问题,angular的ng-model模型绑定本来是可以自动同步的,也就是修改变量值后,页面中显示的内容会同步发生改变。但是套了一层ng-include之后有时也会失去这个特性。。初步猜测也是因为这个作用域变化的问题。解决方法也很简单,每次修改变量值后,通过\\$scope.\\$apply()手动传播一下model的变化(即更新页面)即可。

这两个问题解决之后很自然引入了一个叫做“angular作用域”的概念,虽然暂时也没有别的理解,但是用$parent来追踪父-子关系应该可以解决大部分问题了。

3. 写html页面的一点方法

其实我觉得写html要比js要累的多。。但是又不得不写。。个人觉得一个好的html代码应该要尽量做到以下几点:

  1. 前、后端、样式分离
  2. 对于每一块页面结构(比如一行)都应该包含在一个块里(比如一个\
    ),同时尽量减少多余的父元素(便于样式设计)
  3. 样式复用率尽可能高
  4. class和id的命名有一定层级逻辑(但是又不能太复杂),用同样的class控制同样的样式,确保id唯一,便于用getElementById获取

比如如果我要设计下图这个界面:
在这里插入图片描述
那么首先先大致根据行来分层,找出样式相同的行
在这里插入图片描述
然后先用< div >把整体框架写出来,同时设置好class属性,将相同的行设置成相同的class

<div class='container'>
    <div class='title_row'>
    </div>
    <div class='input_row'>
    </div>
    <div class='input_row'>
    </div>
    <div class='input_row'>
    </div>
    <div class='input_row'>
        <div class='identify_div'>
        </div>
        <div class='identify_div'>
        </div>
    </div>
    <div class='input_row'>
    </div>
    <div class='button_row'>
    </div>
    <div class='return_row'>
    </div>
</div>

这样子先把整体框架定下来之后样式的问题就比较容易解决,而且即使某个部分写乱了也只是在一个\

的范围里,不会破坏整体的思路。

2. 在线测验功能实现

这个功能实现的步骤:

step1:[教师用户]在网页中输入题目描述、选项和正确项,上传一道选择题
step2:[学生用户]收到选择题后可以进行作答,提交后提示回答正确与否,并将回答情况返回至教师端
step3:[教师用户]可以同步看到两张反映回答情况的echarts图表

每一步中间的数据交换(学生端和教师端的中间桥梁)都由websocket服务器完成。

下面按照一次完整的在线测验的时间顺序把逻辑和代码理一遍。

  1. class页面设置两个div,分别对应学生和教师看到的内容,用ng-show控制可见性
    <div ng-show='!isTeacher' class='container_student'>
    </div>
    <div ng-show="isTeacher" class='container_teacher'>
    </div>
    
    $scope.isTeacher在课堂页面加载时完成初始化,读取cookie决定用户身份
    var loginType=getCookie('loginType')
    if (loginType=='0') $scope.isTeacher = false;
    else $scope.isTeacher = true;
    
    对应的两种初始画面
    在这里插入图片描述
    在这里插入图片描述
  2. 教师页面将题目描述、选项和正确项都用ng-model模型绑定,作为提交的内容
    <div class='problem_upload'>
     <div class='problem_description_row'>
         <input ng-model='$parent.problem_des' class='problem_description' placeholder="输入题目描述">
     </div>
     <div class='problem_options_row'>
         <input ng-model='$parent.problem_opA' class='problem_options' placeholder="输入选项A">
         <input ng-model='$parent.problem_opB' class='problem_options' placeholder="输入选项B">
     </div>
     <div class='problem_options_row'>
         <input ng-model='$parent.problem_opC' class='problem_options' placeholder="输入选项C">
         <input ng-model='$parent.problem_opD' class='problem_options' placeholder="输入选项D">
     </div>
     <div class='correct_answer'>
         <div class='correct_answer_text'>正确答案:</div>
         <div class='problem_correct_row'>
             <input ng-model='$parent.correct_option' class='correct_options' type='radio' value='0' name='co'>
             <div class='correct_text'>选项A</div>
         </div>
         <div class='problem_correct_row'>
             <input ng-model='$parent.correct_option' class='correct_options' type='radio' value='1' name='co'>
             <div class='correct_text'>选项B</div>
         </div>
         <div class='problem_correct_row'>
             <input ng-model='$parent.correct_option' class='correct_options' type='radio' value='2' name='co'>
             <div class='correct_text'>选项C</div>
         </div>
         <div class='problem_correct_row'>
             <input ng-model='$parent.correct_option' class='correct_options' type='radio' value='3' name='co'>
             <div class='correct_text'>选项D</div>
         </div>
     </div>
     <!-- {{correct_option}} -->
    </div>
    <input  ng-disabled='correct_option==undefined' ng-click='problemSubmit()' type='button' class='problem_submit' value='上传题目'>
    
  3. 点击提交按钮后调用problemSubmit(),发送websocket请求(同时将统计数据清零,使echarts可见)
    $scope.problemSubmit = function() {
     var data={
         type:'problem_upload',
         des:$scope.problem_des,
         opA:$scope.problem_opA,
         opB:$scope.problem_opB,
         opC:$scope.problem_opC,
         opD:$scope.problem_opD,
         cor:$scope.correct_option
     };
     //统计数据清零
     $scope.amount_opA=0;
     $scope.amount_opB=0;
     $scope.amount_opC=0;
     $scope.amount_opD=0;
     $scope.showResult=true;
     correct_data=[];
     tot=cnt_cor=0;
     //更新echarts图表
     $scope.showEcharts();
     websocket.send(JSON.stringify(data));
    }
    
  4. Websocket服务器处理题目上传请求,将题目内容和正确答案广播
    if (str.type=='problem_upload') {
     let mes={};
     mes.type="problem_assign";
     mes.data={
         des:str.des,
         opA:str.opA,
         opB:str.opB,
         opC:str.opC,
         opD:str.opD,
         cor:str.cor
     };
     broadcast(JSON.stringify(mes));
    }
    
  5. 学生页面接受WS推送,通过数据的双向绑定,使题目内容直接在页面上显示(用\$scope.\$apply()手动更新避免不同步)。将正确项记录在$scope.test_cor中用于比对。
    if (type == 'problem_assign'){
     $scope.showTest = true;
     $scope.test_des=data.des;
     $scope.test_opA=data.opA;
     $scope.test_opB=data.opB;
     $scope.test_opC=data.opC;
     $scope.test_opD=data.opD;
     $scope.test_cor=data.cor;
     $scope.testSubmit_disabled=false;
     $scope.test_msg='';
     $scope.$apply();
    }
    
    学生页面的前端代码,同样将选项用ng-model绑定。
    <div ng-show='!isTeacher' class='container_student'>
     <div ng-show='showTest' class='problem_test'>
         <div class='test_title'>测验题目</div>
         <div class='test_proplem'>
             <div class='test_description'>
                 题目描述:{{test_des}}
             </div>
             <div class='test_options_row'>
                 <input ng-model='$parent.test_option' class='test_options' type='radio' value='0' name='op'>
                 <div class='test_options_text'>A : {{test_opA}}</div>
             </div>
             <div class='test_options_row'>
                 <input ng-model='$parent.test_option' class='test_options' type='radio' value='1' name='op'>
                 <div class='test_options_text'>B : {{test_opB}}</div>
             </div>
             <div class='test_options_row'>
                 <input ng-model='$parent.test_option' class='test_options' type='radio' value='2' name='op'>
                 <div class='test_options_text'>C : {{test_opC}}</div>
             </div>
             <div class='test_options_row'>
                 <input ng-model='$parent.test_option' class='test_options' type='radio' value='3' name='op'> 
                 <div class='test_options_text'>D : {{test_opD}}</div>
             </div>
             <div class='text_btn_row'>
                 <input class='test_options_btn' ng-disabled='test_option==undefined || testSubmit_disabled' ng-click='$parent.testSubmit()' type='button' value='提交'>
             </div>
             <div class='text_signal_row'>
                 <div class='test_signal'>{{test_msg}}</div>
             </div>
         </div>
     </div>
     <div ng-show='!showTest' class='problem_test_signal'>
         <div class='problem_test_signal_text'>等待教师上传题目。</div>
     </div>
    </div>
    
    对应效果:
    在这里插入图片描述
  6. 学生作答(点击提交按钮后),将选择的选项发送给WS,并将选项与正确项比对,提示回答正确与否。同时通过ng-disabled将提交按钮设置为不可用,避免反复提交。

    $scope.testSubmit = function() {
     var data = {
         type:'text_upload',
         op:$scope.test_option
     }
     console.log(data);
     websocket.send(JSON.stringify(data));
    
     if ($scope.test_cor=='0') text='A';
     if ($scope.test_cor=='1') text='B';
     if ($scope.test_cor=='2') text='C';
     if ($scope.test_cor=='3') text='D';
     if ($scope.test_option==$scope.test_cor) {
         $scope.test_msg='回答正确!';
     }
     else $scope.test_msg='回答错误~正确答案为 : '+ text;
     $scope.testSubmit_disabled=true;
    }
    
  7. WS再次处理学生端的请求,将学生的选项广播。
    if (str.type == 'text_upload') {
     let mes={};
     mes.type='problem_submit';
     mes.data={
         username:conn.nickname,
         choice:str.op
     };
     broadcast(JSON.stringify(mes));
    }
    
  8. 最后由教师端接受ws推送,更新数据统计,更新图表。
    if (type == 'problem_submit') {
     if (data.choice=='0') $scope.amount_opA++;
     if (data.choice=='1') $scope.amount_opB++;
     if (data.choice=='2') $scope.amount_opC++;
     if (data.choice=='3') $scope.amount_opD++;
     var text='';
     if (data.choice==$scope.test_cor) {
         cnt_cor++;
     }
     tot++;
     $scope.$apply();
     correct_data.push((cnt_cor/tot*100).toFixed(2));
     $scope.showEcharts();
    }
    
  9. 图表的更新方式($scope.showEcharts):同时更新饼状图和折线图(正确率通过:正确个数/总提交数进行计算并保留两位小数,添加至correct_data数组中,将该数组作为折线图data的参数)
    $scope.showEcharts= function() {
     var myChart = echarts.init(document.getElementById('echarts_pie'));
     var option = {
         title: {
             text: '测验情况'
         },
         tooltip: {},
         legend: {
             orient:'horizontal',
             x:'right',
             y:'top',
             itemWidth:24,
             itemHeight:18,
             textStyle:{
                 color:'#666'
             },
             itemGap:10,
             data:['选项A','选项B','选项C','选项D']
         },
         series: [{
             name: '测验情况',
             type: 'pie',
             radius:'70%',
             data: [
                 {value:$scope.amount_opA, name:'选项A'},
                 {value:$scope.amount_opB, name:'选项B'},
                 {value:$scope.amount_opC, name:'选项C'},
                 {value:$scope.amount_opD, name:'选项D'}
             ],
         }]
     };
     myChart.setOption(option);
     var myChart = echarts.init(document.getElementById('echarts_line'));
     var option = {
         title: {
             text: '正确率'
         },
         tooltip: {},
         legend: {
             data:['正确率']
         },
         xAxis: {
             data: []
         },
         yAxis: {
             min:0,
             max:100
         },            
         series: [{
             name: '正确率',
             type: 'line',
             data: correct_data
         }]
     }
     myChart.setOption(option);
    }
    
    效果如图
    在这里插入图片描述
    这样一来就完成了一次完整的在线测验。虽然这样列出来之后感觉步骤挺多的,但是其实写起来也没有这么复杂,因为每一步之间的逻辑关系其实很容易理解,反倒是一些细节(比如设置提交后按钮不可用来避免反复提交这种)需要多完善一下。目前的写法还有一个缺陷就是所有交互都是由websocket完成的,没有http请求,也就意味着所有数据都是在线的,题目和作答情况没有保存,一旦刷新页面或者退出重进后就会丢失所有的数据,需要重新上传题目。

3. 热点广播

其实就是第一篇里面讲的用树莓派作为服务器,wifi热点访问网站的内容。这里默认已经开启了wifi热点功能并且能够给连接的设备分配ip地址了(具体方法看第一篇)。只需要将整个express文件夹发送给树莓派上,安装nodejs、mysql和一些依赖项之后直接运行就可以了。

网站文件夹发送

这里直接使用了VNC的文件发送功能,比较方便的就是可以直接发文件夹当然发压缩包也可以但是在树莓派系统下解压rar好像略麻烦
在这里插入图片描述
直接cd到文件夹目录之后node app运行一下,然后电脑连接树莓派wifi,发现连接wifi的设备已经可以通过ip地址正常连接到主页了
在这里插入图片描述
不过当然因为还没有装数据库,所以试图登录的话网站就会崩溃掉。

树莓派安装数据库

这个方法好像也挺多的。我安装的是MariaDB而不是MySQL,这两个数据库在绝大多数方面是兼容的,单从使用的角度讲好像也没有什么不同,主要就是MariaDB装起来比较方便。

安装方法就是通过apt-get命令(如果没有的话可能要先apt install update)

sudo apt-get install default-mysql-server

安装完毕之后
sudo mysql -u root -p进入数据库之后先设置一下root密码

use mysql;
set passwords=PASSWORD('root');
flush privileges;

然后正常把我们要用的数据库和表建好就ok了

create database myDB1;
use myDB1;

CREATE TABLE `login`(
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(200) NOT NULL,
    `passwords` VARCHAR(200) NOT NULL,
    `loginType` INT NOT NULL,
    PRIMARY KEY(`id`),
    UNIQUE KEY `id_UNIQUE` (`id`),
    UNIQUE KEY `url_UNIQUE` (`username`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

测试一下注册、登录系统都没有问题,这样一来数据库也算是建好了(至少能用)。

手机端测试

手机也可以通过热点登陆一下试试。虽然没有特意设置过,但是手机端的兼容性也还算不错。
在这里插入图片描述
在这里插入图片描述
似乎可以通过加一些meta标签来使网页能自适应手机端的比例和尺寸。比如说

<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0" name="viewport">

不过效果好像也一般。

4. 小结

忙活半天发现也只是把项目的五条基本要求完成了,还有四条扩展要求好像还没有怎么接触到的样子。其中canvas绘图的部分应该相对简单,视频推流和人脸识别应该就需要再补充更多其他知识才能实现了。

单从目前做这些基本要求的内容来说,从前端界面到后端代码结构再到技术理解方面我感觉完成度都还是比较高的(毕竟大多数内容都写了两遍)。后续扩展内容可能会侧重于先把技术方面需要的知识学一下,至于一些前端界面可能就放到最后,如果还有时间的话就再去做一下。也就是不那么追求整体的完成度了