一个基于Node的视频网站的实现

前言

这是一个基于NodeJS + ExpressJS + MongoDB实现的一个视频内容管理系统。包括用户登录、注册、用户管理、电影资源录入、视频播放和评论回复等功能。这里记录一下在开发过程中的一些技术要点。o(////▽////)q

篇幅很长~具体项目可在这里查看~

MVC开发模式

在开发交互应用中,将不同功能的代码拆分到不同的文件或者区块中,降低代码的耦合度,提高代码的可读性和健壮性。(将Model、View、Controller三部分代码拆分到不同的文件)

  1. 服务器端开发

    • Model:指和处理业务数据相关的代码。(实现数据库的增删改查)
    • View:指和页面组装相关的代码,主要和各种模板引擎相关的代码。(ejs)
    • Controller:指和用户行为相关的代码 在网站后平台应用中指对各种http请求的Handler,也就是根据不同的URL和http请求参数,将数据和模板绑定在一起,最终形成页面,呈现给用户。并于router(定义网站的URL规则)紧密相关。
  2. 前端开发
    • Model:相当于后台数据的镜像池,与服务器端Model概念相同。
    • View:对页面的实现,Html、CSS相关代码。
    • Controller:前台用户和网页的交互主要是通过操作事件(点击/input等)实现,所以前端的controller可以理解为各种交互事件的handler。
  3. 根据页面中的字段,提取和抽象,来设计数据库。(Mongoose)
    • Schema(模型):描述文档结构、数据类型。对数据字段进行定义,定义字段的类型。String、Boolean等。定义模型的实例方法,通过实例来调用(Schema.methods={});定义模型的静态方法,通过模型来调用(Schema.statics={});定义中间件,定义在某一个动作之前干什么(Schema.pre("",function(next){...;next();}))。

Grunt

  1. 前端自动化构建工具,配置大于编码。会自动帮你将代码合并(concat)、压缩(uglify)、语法检查(jshint)、自动编译less(contrib-less)和sass(contrib-sass)、压缩图片(contrib-imagemin)、读写拷贝移动文件等等,极大地简化了你的工作,它有很多插件,前面说到的每个功能都是一个插件。一个项目刚刚开始用grunt管理的时候,首先在项目的根目录下新建两个文件:package.json 和 Gruntfile.js。package.json里面放项目名称啊、版本号、描述、项目用到的grunt插件;
  2. Grunt通过grunt对象暴露所有方法和属性,并将此对象赋予module.exports函数。(module.exports = function(grunt){})Gruntfile.js是用来配置grunt任务的,它还可以用来加载grunt插件。
  3. grunt.loadNpmTasks加载grunt插件和任务。此插件必须通过npm安装到本地,并且是参照 Gruntfile 文件的相对路径。
  4. grunt.initConfig配置这些任务插件的任务,Grunt的task配置都是在 Gruntfile 中的grunt.initConfig方法中指定的。任务配置里面配置了哪个任务,那么下面就要用grunt.loadNpmTasks加载对应的插件,任务配置是以任务名称命名的属性,每个任务的具体配置参数可以找到对应的插件进行查看。
  5. grunt.registerTask注册 “别名任务” 或 任务函数。当运行一个任务时,Grunt会自动查找配置对象中的同名属性。

密码存储 安全无小事

  1. (用户提交的明文密码 + 加盐)==>hash==>存储。
  2. 需要使用不可逆的算法来保存加密后的密码,同时需要在用户登录的时候验证密码是否正确。单向函数md5:把任意数量的数据转换成固定长度的指纹,过程不可逆,输入变结果的Hash值会有很大的不同。一般网站保护密码都是使用加盐后的密码Hash后的值。加盐指的是在用户自定义的密码中加入其他成分,然后来增加系统的复杂度。一些恶意破解密码的方式:字典、查表、暴力。
  3. 使用bcrypt 一个npm库专门为密码存储设计的算法。

注册用户存到数据库中

  1. 前端表单,提交地址,submit直接就将页面给提交走了;
  2. 后台Schema约定了用户里的字段,包括对密码加密的处理;
  3. 将Schema发布为Model;
  4. 路由里接收从表单提交的数据,处理成对象。var _user = req.body.user;通过req.param(‘user’)也可以拿到这个_user对象。req.param()是对提交body、query、包括路由的三种封装的内容都可以获取到。下面三种表达式的封装,但有优先级,最好分开。param()先去路由里拿变量–>body里–>query里

    • 路由为/user/signup/:userid 路由中的变量 req.params.userid
    • 路由为/user/signup/1111?userid=1112路由中的查询字符串 req.query.userid
    • 通过ajax异步提交过来的,在ajaxpost的body中,通过req.body.userid
  5. 让这个对象object借助模型Model生成一条用户数据,存储到数据库。(app.use(express.bodyParse)利用中间件,将post过来的body里的数据初始化为一个对象)。(实例具有数据库操作CRUD 增删改查var user = new Model(object);user.save(cb))

一些业务逻辑

  1. JQuery 异步提交一个删除的请求,后台接收到请求之后再把这个数据删除,返回一个删除成功的标志。通过异步的方案而不需要页面刷新来告知用户,用户名被注册过,以及给出一些建议用户名、密码不对、重新输入等。
  2. app.locals.变量显示用户登录状态,将user信息存放成本地变量,在每个ejs页面中都能拿到user,不用每次都用render传递user。

    1
    2
    3
    4
    5
    6
    //pre handle user   预处理
    app.use(function(req, res, next) {
    var _user = req.session.user || 0;
    app.locals.user = _user;
    return next();
    })
  3. 在入口文件中放太多的路由处理,没有体现模块分离的原则。

  4. 开发环境下报错有关的配置/代码压缩程一行可读性不好/开发环境下应该看到的报错信息/本地开发时关心从客户端到服务器有多少个请求/请求类型/请求状态/关心数据库里查询的一些状态(可以通过在入口文件里通过配置本地的开发环境的一些变量,达到在控制台看到这些信息)。
  5. 本地环境/线上测试环境/正式生产环境
  6. 项目推进–>功能点增加–>项目体积变大–>根据现有的业务的特点梳理好业务流程,就能对项目结构做逐步优化,目的是为了适应将来一段时间内的开发需求。

如何保持用户的状态(登陆失败、成功等)

  1. 利用session。用express app的use方法将session挂载在‘/’路径即可,这样所有的路由都可以访问到session。一旦我们将express-session中间件用use挂载后,我们可以很方便的通过req参数来存储和访问session对象的数据。req.session是一个JSON格式的JavaScript对象,我们可以在使用的过程中随意的增加成员,这些成员会自动的被保存到option参数指定的地方,默认即为内存中去。
  2. 有时候,我们需要session的声明周期要长一点,比如好多网站有个免密码两周内自动登录的功能。基于这个需求,session必须寻找内存之外的存储载体,数据库能提供完美的解决方案。这里,我选用的是mongodb数据库,作为一个NoSQL数据库,它的基础数据对象时database-collection-document 对象模型非常直观并易于理解,针对node.js 也提供了丰富的驱动和API。express框架提供了针对mongodb的中间件:connect-mongo,我们只需在挂载session的时候在options中传入mongodb的参数即可,程序运行的时候, express app 会自动的替我们管理session的存储,更新和删除。app会自动替我们把session存入到mongodb数据,而非内存中。
  3. 由于session是存在服务器端数据库的,所以的它的生命周期可以持久化,而不仅限于浏览器关闭的时间。具体是由cookie.maxAge 决定:如果maxAge设定是1个小时,那么从这个因浏览器访问服务器导致session创建开始后,session会一直保存在服务器端,即使浏览器关闭,session也会继续存在。如果此时服务器宕机,只要开机后数据库没发生不可逆转的破坏,maxAge时间没过期,那么session是可以继续保持的。当maxAge时间过期后,session会自动的数据库中移除,对应的还有浏览器的cookie。不过,由于connect-mongo的特殊机制(每1分钟检查一次过期session),session的移除可能在时间上会有一定的滞后。
  4. 当然,由于cookie是由浏览器厂商实现的,cookie不具有跨浏览器的特性,例如,我用firefox浏览器在京东上购物时,勾选了2周内免密码输入,但是当我第一次用IE登陆京东时,同样要重新输入密码。所以,这对服务器的同一个操作,不同的浏览器发起的请求,会产生不同的session-cookie。
  5. secret 是防止session不被盗取和篡改,可以随便设置内容。应用基于HTTP POST或HTTP GET请求发送请求时,为了确保应用与服务器之间的安全通信,服务器使用了参数签名机制。应用在调用Open API之前,需要为其所有请求参数计算一个MD5签名,并追加到请求参数中。secret这个参数就是做参数签名时所需要的签名密钥。服务器在接收到请求时会重新计算签名,并判断其值是否与应用传递过来的参数值一致,以此判定当前Open API调用请求是否是被第三者伪造或篡改。
1
2
3
4
5
6
7
8
9
10
var session = require('express-session');
app.use(session({//配置项
secret:'imooc',
store:new mongoStore({//创建新的mongodb数据库
url:dbUrl,//数据库的地址
collection:'sessions'
}),
resave:false,
saveUninitialized:true
}))

用户权限控制

  1. 首先调整用户模型schema,即在数据库中的字段的结构,增加role,用一种机制来控制用户的层级。
  2. role可以是String类型,角色值有admin、user、super admin等。
  3. 更可以是Number类型,划定一些数值的区间,默认注册用户角色为0(nomal user),通过邮件激活的用户角色为1(verfied user),资料填的很完备,可以称为高级用户角色为2(professional user),角色3~9留空,用作将来用户中的管理员,可以逐步升级。本app中>10:大于10,就是管理员角色。>50:可能不会存到数据库,就用于本地开发,超级管理员。
  4. 不管有多少种用户类型,不需要维护String类型的好多值,只需要维护数字就可以了。
  5. 在页面的控制逻辑中拿到var user = req.session.user;if(!user){return res.redirect('/signin');if(user.role>10){再展现一些代码}}
  6. 各司其职,用中间件来负责用户权限的校验,包括是否登陆状态的判断app.get("/admin/user/list", User.signinRequired, User.adminRequired, User.list);

关于ObjectId

  1. 主键。一种特殊而且非常重要的类型,每个Schema都会默认配置这个属性,属性名为_id,该类型的值由系统自动生成,从某种意义上说不会重复。自己定义方可覆盖。
  2. 同时默认索引也是利用主键来索引。
  3. 在mongoDB中没有关系型数据库里的join特性,所以mongoose封装了一个populate的功能,也就是当你在定义Schema时指定某一个字段是引用另外一个schema,那么在获取document时,就可以通过populate的方法,让mongoose帮你通过引用Schema和id找到关联的文档。然后用这个文档的内容替换掉原来引用字段的内容。这样引用的文档使用起来就像内嵌的文档一样方便。
  4. populate方法可以用在文档、模型、或者Jquery对象上。也就是几乎可以在任何地方调用这个方法来填充字段

    1
    2
    3
    4
    5
    6
    populate()//实现关联查询 可以用在文档、模型、或者Jquery对象上。
    - path //空格分隔引用字段名称
    - select //document中有哪些字段
    - match //可选
    - model //可选 引用model
    - option //可选 查询选项
  5. 引用document主键类型,必须和引用它的字段类型相对应

回复功能

  1. 我可以评论别人的回复,它也可以对我进行回复,除了我俩另外一个人也可以对我们之间的对话进行回复。这样一个多对多的方式。
  2. 点击头像就能回复:在头像上增加<a></a>链接,其实就是个锚点,能够跳到最底下的评论区,然后给它添加一些附带的data的数据实现。比如当前主评论的id,data-cid;当前评论人的id,data-tid.
  3. 在某个主评论下方回复的,在后台拿到主评论的id进行更新,所以要动态的往Form表单中插入一些隐藏域,所以就需要JS的方式来协助。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //用ObjectId作为字段的类型主键,也是为了实现关联文档的查询
    var ObjectId = Schema.Types.ObjectId;
    var CommentSchema = new Schema({
    //当前要评论的电影是那一部,指向Movie模型
    //通过引用的方式存一个电影的id
    movie:{type:ObjectId,ref:"Movie"},
    //评论来自于谁
    from:{type:ObjectId,ref:"User"},
    //具体评论内容
    content:String,

    //一个主评论,下面很多针对这个主评论的小评论
    //评论:回复给谁?谁回复的?回复的内容
    reply:[{
    from:{type:ObjectId,ref:"User"},
    to:{type:ObjectId,ref:"User"},
    content:String,
    }],
    });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var Comment = require("../models/comment");
exports.save = function(req, res) {
var _comment = req.body.comment;
var movieId = _comment.movie;
//判断是否是回复,有没有cid,是评论就不是要new一个comment了
if(_comment.cid){
Comment.findById(_comment.cid,function(err,comment){
var reply = {
from:_comment.from,
to:_comment.tid,
content:_comment.content
}

//然后往找到的这个reply数组里面push这个reply
comment.reply.push(reply);
comment.save(function(err,comment){
if(err){
console.log(err);
};
res.redirect('/detail/'+movieId);
})
})
}else{
var comment = new Comment(_comment);
comment.save(function(err,comment){
if(err){
console.log(err);
};
res.redirect('/detail/'+movieId);
});
}
};

详情页

  1. 异步方式,让Movie和Comment各自去查,最后来一个结果的整合;回调的方式,查到当前movie,拿到movieid后,再来查comment,里面这部电影下多有的comment。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    exports.detail = function(req, res){
    var id = req.params.id;
    Movie.findById(id,function(err,movie){
    if(err){
    console.log(err);
    };

    //找到这些电影,找到评论过这个电影的所有评论
    //对每条评论数据进行populate方法,它里面的from对应的objectid去user表里面查
    // 查完之后把name的值返回,来填充from字段
    //然后执行exct方法,回调方法里面传入err,和comments
    Comment
    .find({movie:id})
    .populate("from","name")
    .populate("reply.from","name")
    .populate("reply.to","name")
    .exec(function(err,comment){
    if(err){
    console.log(err);
    };
    res.render("pages/detail",{
    title:"imooc 详情页",
    movie:movie,
    comment:comment
    });
    });
    });
    };

分类功能

  1. 一个粗暴的实现方式是把分类也看作是一个电影的字段,每次存储一条电影数据的时候,就手动的写一下这个分类的名字,那么它就作为一个字符串类型的字段存到电影的一个数据里。缺点在于,假如说现在有10000部电影,但是里面到底有多少种分类,其实如果里面没有以一种文档的方式记录下来,是不清楚的。就必须将这些电影全部遍历一遍,对里面分类的名字比对一遍,才能知道一共有多少种分类,每一个分类的名字是什么。
  2. 所以采用单独建立一个分类的表来管理一共有多少种分类,分类的名字,包括分类的添加和删除,然后将电影的表和分类的表之间建立一个对应的关系。
  3. 可以通过分类选择,选择该电影的分类,如果现在分类里没有想要的,可以在电影分类里自行输入,同时在后台创建该分类。
  4. 如果说一不小心又填了,也点了分类的话,以点的分类为主

豆瓣数据同步

  1. 大量的录入电影数据是一个很麻烦的事情,可以使用豆瓣开发者服务,使用提供的接口,来请求豆瓣的数据。在页面上放置一个input文本域,输入一个电影id(豆瓣上搜索某一电影url后的一段数据),注册一个blur事件,当id输入之后,随便点击页面一个地方,使文本域失去焦点触发这个事件。调用Ajax,使用JSONP请求来跨域获取资源,自动填充一些字段。

增加分类列表页和分页

  1. 通过在首页将分类的title变为一个链接,用来传递分类的参数。将某一类下所有的数据分页。每页只显示2条查询结果。分页用参数p表示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    exports.search = function(req, res) {
    var catId = req.query.cat;//拿到分类
    var page = req.query.p;//拿到分页
    var index = page * 2;

    Category
    .find({_id: catId})
    .populate({
    path:'movies',
    select: 'title poster',
    options:{limit:2,skip:index}//限制它每一次查询的个数,以及从那一条数据开始查,跳到索引的位置开始查询
    })
    .exec(function(err,categories){})
    }
  2. 如上所示设置,根据url查询参数获得分类、分页,获得该分类下所有数据,显示由分页决定的索引处开始的2条数据。但实际的显示结果并不理想。原因是mongoose在api的使用上还是有一些问题,导致skip并不能很好的工作,不能真的做到某一段数据的选取。所以自己实现一个分页的逻辑。

  3. 搜索类型和分类类型,公用一套模板文件,所以要做好识别。通过正则的方式,使得可以不用通过传全部的名字就可以搜索结果。

增加海报上传的功能 && 访客统计

  1. 修改表单的属性,增加一个属性,enctype="multipart/form-data",使得在提交这个表单的时候,可以将里面的数据分隔,从而能达到多种数据混合上传的目的。在海报的位置下面加一个file类型的文本域,海报上传。看是否上传了一张海报,如果没有上传这个值的话,就用上面的海报地址。后台通过name值来接收这个file,而不是存到movie的schema里。
  2. fs模块读取文件是异步的,所以很难控制整个流程,可能是我这个文件还没有保存好,那么我接下来这个movie的存储和这个category的存储都已经完成了,所以我要保证一定实在我文件上传好之后才开始进行这个电影数据的存储和更新。通过route增加一个中间件来实现,中间件就是属于整个流水线中的某一个环节,在这个环节中,对请求过来的请求的数据进行一些处理。然后,处理如果没有问题的话,就酱处理后的数据,交给下一个流程去处理。
    app.post("/admin/movie", User.signinRequired, User.adminRequired,Movie.savePoster, Movie.save);
  3. 静态方法,给Movie设置一个字段pv,通过实例调用,每一次打开detail页面就加一次。
    Movie.update({_id:id},{$inc:{pv:1}}),

单元测试

  1. 单元测试的意义:不仅仅是保证单次的工程质量,也是为了满足一个项目后期不断的需求更改,和细节的重构,同时能够节约大量人工检查的时间和人工定位bug的时间,从而将这个时间做一些更有意义的事情。
  2. 测试用例的设计、分层模块测试、100%测试的覆盖率,各种场景平台下不同的驱动方式和测试理念。流行的测试框架:mocha、QUnit、Jasmine等。
  3. 记得随时清理掉测试数据,不至于对数据库产生影响。
  4. 除了使用单元测试也可以使用jshint这么一个插件,对我们的编程习惯做一个约束。能保证我们在一个项目里面代码风格保持一致。
  5. descript描述一个测试单元的开始,descript可以嵌套,就是说一个测试模块里可以有子的测试模块,所以可以分组。首先测试关于User的模型,before,after,在测试用例运行之前要干的一些事情,比如预定义好一些变量等等。测试从done的调用开始。一个it就代表一个测试用例,只要跑通一个it里面所定义的这些比对这些任务,就说明跑通啦~