nodeclub 是 cnodejs.org 的源码,CNode 算是一个基本的博客系统,包含文章发布, 关注,评论等功能。这些功能可以说是任何一个网站的基础。从 nodeclub 里可以学到什么?
1.基本的架构
2.开发测试过程
3.MVC 的设计
4.middleware 的正确用法
5.如何设计 Mongodb schema
6.如何正确的使用 Mongoose
7.如何实现一个标签系统
8.plugins? services ?
9.如何正确的使用 EJS helper
10.到底该怎样写路由, restful?
11.如何做基本的控制验证
12.如何发邮件
13.session
14.GitHub 用户登录
15.图片上传
16.消息发送
nodeclub源码
对于想用 nodejs + express + mongodb 来做网站技术基础的项目, nodeclub 可以说是很好的源码级指南。
express: 基础框架:
mongodb: 数据存储
mongoose: orm
connect-mongo: session (对于redis, 可以使用connect-redis)
nodemailer:邮件
validator:验证
passport,passport-github: passport,
loader: ejs-view-helper, 静态资源加载处理
其他: event-proxy, node-markdown, ndir
测试框架:mocha, should
运行: forever
请求模拟: supertest
Model: 对应mongoose orm, models目录
view: ejs模板, views目录
controler:express middleware , contollers目录
神圣的入口文件,几乎每个项目都会有一个 entry,对于了解一个应用熟悉入口逻辑很重要。 下面将分步来看看,nodeclub 的 app.js 做了什么:
3.1.1 应用相关的配置的设置, 主要分为
1.应用全局数据配置
2.数据库连接配置
3.session,auth 相关配置
4.rss配置
5.mail配置
6.第三方连接相关配置, github, weibo
配置文件也是了解应用的一个好地方, 在 config.default.js 中可以看到以下信息, 这些很可能是我们平时做应用开发的时候没有留意到的地方
//--应用数据统计
google_tracker_id: 'UA-41753901-5',
//--静态文件很可能使用cdn来做
site_static_host: '', // 静态文件存储域名
//--求解释
site_enable_search_preview: false, // 开启google search preview
site_google_search_domain: 'cnodejs.org', // google search preview中要搜索的域名
//--运营数据
list_topic_count: 20,
post_interval: 10000,
admins: { admin: true },
side_ads:[]
allow_sign_up: true,
//--插件模式
plugins: []
3.1.2 当然这里的配置文件是 default 的,配置文件可以放在一个 config 的文件夹下面,多个文件的方式来整理。比如运营数据配置和其他数据配置分开,因为很有可能需要做一个小的工具来让非技术人员配置相关参数。这时候可以用一个 index.js 作为 facade,相当于一个大的 node module。
3.2.1 之前已经讲了 models/ 目录对应 MVC 的 M 部分。
3.2.2 models/ 目录下面有 index.js, require(‘./models’) 相当于 require(‘./models/index’)
index 相当于一个模型的 facade, index.js 做得事情分别是
1.connect mongodb
2.require 各个 model 模块
3.exports 所有的 model
简单而言就是初始化了应用 model 层。
3.2.3 模型使用 orm 框架 mogoose 来写,了解 mogoose 过后, models 部分的代码也就是秒懂了
, 我说的只是代码,literaly, 一个项目的核心就是 model 的设计,以前做过的任何项目都是一样, 数据库 table 的设计好坏直接影响应用的开发以及性能。 下面来看看各个 model 的 schema 设计(几乎直接 ctr+c, ctr+v 加上了一点点注释) :
3.2.4 user
var UserSchema = new Schema({
//--基本用户信息, index表示在mongodb中会建立索引
//--unique: true 唯一性设置
name: { type: String, index: true },
loginname: { type: String, unique: true },
pass: { type: String },
email: { type: String, unique: true },
url: { type: String },
profile_image_url: {type: String},
location: { type: String },
signature: { type: String },
profile: { type: String },
weibo: { type: String },
avatar: { type: String },
githubId: { type: String, index: true },
githubUsername: {type: String},
is_block: {type: Boolean, default: false},
//--用户产生数据meta
score: { type: Number, default: 0 },
topic_count: { type: Number, default: 0 },
reply_count: { type: Number, default: 0 },
follower_count: { type: Number, default: 0 },
following_count: { type: Number, default: 0 },
collect_tag_count: { type: Number, default: 0 },
collect_topic_count: { type: Number, default: 0 },
create_at: { type: Date, default: Date.now },
update_at: { type: Date, default: Date.now },
is_star: { type: Boolean },
level: { type: String },
active: { type: Boolean, default: true },
//-mail
receive_reply_mail: {type: Boolean, default: false },
receive_at_mail: { type: Boolean, default: false },
from_wp: { type: Boolean },
retrieve_time : {type: Number},
retrieve_key : {type: String}
});
3.2.5 topic 话题
//1 <- 多
//tag <- topic <- collect
var TopicSchema = new Schema({
title: { type: String },
content: { type: String },
author_id: { type: ObjectId },
top: { type: Boolean, default: false },
reply_count: { type: Number, default: 0 },
visit_count: { type: Number, default: 0 },
collect_count: { type: Number, default: 0 },
create_at: { type: Date, default: Date.now },
update_at: { type: Date, default: Date.now },
//--这里reply的设计方式不知道是否合适, 因为mongdb不同于关系型数据库,这里每次读取文章都需要重reply集合里边查找遍历一边,文章是读繁忙的。
//-- 一个document的大小为5Mb, 一本牛津词典的内容, 我觉得将reply放在这里应该不会有太大问题。 即便不存放reply 内容, 存放一个id数组也会好很多。
//-- 客官们怎么看?
last_reply: { type: ObjectId },
last_reply_at: { type: Date, default: Date.now },
content_is_html: { type: Boolean }
});
var ReplySchema = new Schema({
content: { type: String },
topic_id: { type: ObjectId, index: true },
author_id: { type: ObjectId },
reply_id : { type: ObjectId },
create_at: { type: Date, default: Date.now },
update_at: { type: Date, default: Date.now },
content_is_html: { type: Boolean }
});
//--话题集合
var TopicCollectSchema = new Schema({
user_id: { type: ObjectId },
topic_id: { type: ObjectId },
create_at: { type: Date, default: Date.now }
});
//--话题标签
var TopicTagSchema = new Schema({
topic_id: { type: ObjectId },
tag_id: { type: ObjectId },
create_at: { type: Date, default: Date.now }
});
3.2.6 tag 标签系统
//tag <- collect
var TagSchema = new Schema({
name: { type: String },
order: { type: Number, default: 1 },
description: { type: String },
background: { type: String },
topic_count: { type: Number, default: 0 },
collect_count: { type: Number, default: 0 },
create_at: { type: Date, default: Date.now }
});
var TagCollectSchema = new Schema({
user_id: { type: ObjectId, index: true },
tag_id: { type: ObjectId },
create_at: { type: Date, default: Date.now }
});
3.2.7 关系
var RelationSchema = new Schema({
user_id: { type: ObjectId },
follow_id: { type: ObjectId },
create_at: { type: Date, default: Date.now }
});
3.2.8 消息 消息 model 设计, 对于一个 blog 来说, 基本的只有回复消息, 这里加了关注和@消息。
/*
* type:
* reply: xx 回复了你的话题
* reply2: xx 在话题中回复了你
* follow: xx 关注了你
* at: xx @了你
*/
var MessageSchema = new Schema({
type: { type: String },
master_id: { type: ObjectId, index: true },
author_id: { type: ObjectId },
topic_id: { type: ObjectId },
reply_id: { type: ObjectId },
has_read: { type: Boolean, default: false },
create_at: { type: Date, default: Date.now }
});
3.3.1 express 的基础是 middleware,或者说 express 的基础是 connect,connect 的基础是 middleware。middleware 模式在 professional nodejs 中有一个专门的章节来讲解。何为 middleware 呢? middleware 模式 相当于一个加工流水线(大家叫 middleware stack),每一个 middleware 相当于一个加工步骤,当出现一个 http 请求的时候,http 请求会挨着每个 middleware 执行下去。
express 里处理一个请求的过程基本上就是请求通过 middleware stack 的过程: * -> middlewares -> 路由 -> controllers -> errorhandlering。
3.3.2 middleware 怎样做到的, 异步的方法呢? middleware 使用 promise 的方式来处理异步,所有每个 middleware 都有三个参数 req, res, next, 对于异步的情况, 必须要调用 next() 方法。不然后续的 middleware 就无法执行。 ps: debug 的时候没调用 next() 还不会报错,一定注意
3.3.3 auth.js
auth.js exports 出来的函数全部都是中间件,从变量名就完全清楚的知道到底在做什么了
//-- 需要admin权限
exports.adminRequired = function (req, res, next) {}
//-- 需要有用户
exports.userRequired = function (req, res, next) {}
//-- 需要有用户并登录
exports.signinRequired = function (req, res, next) {
if (!req.session.user) {
res.render('notify/notify', {error: '未登入用户不能发布话题。'});
return;
}
next();
}
//-- 屏蔽用户 -_-
exports.blockUser = function (req, res, next) {}
这里其实就可以看到中间件的作用了,我们以前写 php 的时候每次都需要判断用户是否登录, 没登陆 redirect 到 index.php ,只不过这里的方式是通过中间件来处理。
明白这里什么意思,其他的中间件模块也就秒懂了。
3.4.1 express 的世界里另外一个很重要的就是route, Node.js 启动的是服务, 监听了某一端口, 接受 http or https or socket 请求, 那 url 中像 /index.php?blabla 这一串的存在怎么处理呢, express 的 route 功能就可以帮我们解析。
3.4.2 MVC 中如何将一个请求和 controller 联系起来呢, route 就是这样的纽带
//--get, post 请求
app.get('/signin', sign.showLogin);
app.post('/signin', sign.login);
//--使用中间件
app.get('/signup', configMiddleware.github, passport.authenticate('github'));
app.post('/:topic_id/reply', auth.userRequired, limit.postInterval, reply.add);
3.4.3 route 是了解一个应用最佳的地方,一个请求如何处理, 到相应的 controller 去看就知道了。 相比起在PHP环境下配置更加灵活。当然你说你通过nginx来配置也很灵活,好吧,我们说的不是一回事。
3.5.1 experess initialize: app.js 中其他大多部分就是express的初始化了, 初始化流程如下:
1.配置上传 upload_dir
2.模板引擎设置
3.express 通用中间件设置
4.pasport 中间件
5.自定义中间件
1.auth_user
2.block_user
3.staticfile: upload
4.staticfile: user_data
6.csrf
7.errorhandler
8.set view cache
@Note:配置的顺序很重要, 中间件的执行顺序是按照定义顺序来执行的, 如果一个中间件依赖另外的中间件, 而自己先执行了, 这种情况就会错误。 常见的问题就是session配置, 一定要记得配置 session 中间件的时候, 要先配置 cookieParser。
3.5.2 session 设置
这个步骤在 initialize 里边已经有了, 不过再单独讲一下, nodeclub 使用的是 connect-mongo 来作为 session 的存储
//--cookieParser一定要在前面, 因为session的设置依赖cookie
app.use(express.cookieParser());
app.use(express.session({
secret: config.session_secret,
store: new MongoStore({
db: config.db_name,
}),
}));
3.5.3 view helpers
使用过 ejs 的肯定知道, ejs 里边 view helper 设置很简单, 就像赋值变量一样。 当对于一些通用的 helper 可以这样设置:
app.helpers({
config: config,
Loader: Loader,
assets: assets
});
app.dynamicHelpers(require('./common/render_helpers'));
3.5.4 github pasport initialize
// github oauth
passport.serializeUser(function (user, done) {
done(null, user);
});
passport.deserializeUser(function (user, done) {
done(null, user);
});
passport.use(new GitHubStrategy(config.GITHUB_OAUTH, githubStrategyMiddleware));
3.5.5 start app
//--设置能否直接注册, 不能的话通过github注册
if (config.allow_sign_up) {
app.get('/signup', sign.showSignup);
app.post('/signup', sign.signup);
} else {
app.get('/signup', configMiddleware.github, passport.authenticate('github'));
}
app.post('/signout', sign.signout);
app.get('/signin', sign.showLogin);
app.post('/signin', sign.login);
sanitize = validator.sanitize;
check = validator.check;
exports.signup = function (req, res, next) {
//--xss 消毒
var name = sanitize(req.body.name).trim();
name = sanitize(name).xss();
...
//--validations
try {
check(name, '用户名只能使用0-9,a-z,A-Z。').isAlphanumeric();
} catch (e) {
res.render('sign/signup', {error: e.message, name: name, email: email});
return;
}
...
//--用用户名登录或者email登录
query = {'$or': [{'loginname': loginname}, {'email': email}]}
User.getUserByQuery(query, {}, function(){
...
pass = md5(pass);
...
User.newAndSave(name, loginname, pass, email, avatar_url, false, function (err) {
...
// 发送激活邮件
mail.sendActiveMail(email, md5(email + config.session_secret), name);
res.render('sign/signup', {
success: '欢迎加入 ' + config.name + '!我们已给您的注册邮箱发送了一封邮件,请点击里面的链接来激活您的帐号。'
});
})
})
}
一个应用通常会遇到这样的情景, 一个页面需要的数据包括, 文章列表, 评论列表,用户数据,广告数据, other stuff… 问题是每个都是异步的, 怎么办。 user 数据获取过后的 callback 调用文章列表获取, 文章列表获取的 callback 调用评论列表的获取… 这样就太蛋疼了。 nodeclub 使用了 eventproxy 模块优雅的解决这样的问题:
render = function(){}
var proxy = EventProxy.create('tags', 'topics', 'hot_topics', 'stars', 'tops', 'no_reply_topics', 'pages', render);
proxy.fail(next);
Tag.getAllTags(proxy.done('tags'));
Topic.getTopicsByQuery(query, options, proxy.done('topics'));
User.getUsersByQuery({ is_star: true }, { limit: 5 }, proxy.done('stars'));
看完代码不言而喻。。。
当然异步处理的方法有很多:
1.基于事件的:eventProxy
2.基于promise的:Async.js Q.js, when.js
3.基于编译的:continuation, wind
4.基于语言语法的:yield, livescript
文章最后会讲一下我的异步选择方案
7.1.1 一个项目必定离不开测试, nodeclub基于mocha BDD测试框架, 一切的前提假设至少能看懂jasmine或者mocha或者任何一个BDD风格的测试代码。
7.1.2 打开即看到app.js
var app = require('../app');
describe('app.js', function () {
//--before, 执行it的前面会执行
before(function (done) {
//--done, 异步方法
app.listen(3001, done);
});
after(function () {
app.close();
});
it('should / status 200', function (done) {
//--使用 app.request()就可以模拟请求了? 这个api哪里来的, 求解释?
app.request().get('/').end(function (res) {
res.should.status(200);
done();
});
});
});
//--按理说应该是可以正常运行了但是我一直出现这个错误:
//--connect ADDRNOTAVAIL 知道的求解释
//--我尝试用supertest直接测试, 但是也是一直timeout, mocha
//--里边加大timeout时间, 结果就是一直没反应。
//--分析原因, express版本问题, nodeclub中express的版本还是2.x, 所以才会有
//--app.request(), app.close()这些api
//--第二个原因, 到supertest官网, 发现人家都已经转战到superagent项目了, 于是我写了下面这个测试脚本, 可以通过了
var express = require('express');
var should = require('should');
var path = require('path');
var superagent = require('superagent');
var app = express()
app.get('/user', function(req, res, next) {
res.send(200, {
name: 'tobi'
})
})
describe('myapp.js', function() {
this.timeout(5000)
before(function(done) {
app.listen(21, done);
})
after(function() {
// app.close()
})
it('should /status 200', function(done) {
agent = superagent.agent()
agent.get('http://localhost:21/user').end(function(err, res) {
console.log(err, res)
res.should.have.status(200);
res.text.should.include('tobi');
return done();
});
})
})
nodejs是单线程应用, 如果我们用node命令来运行我们的应用, 当出现一个小错误, 它就挂了。 然后没有然后了。 避免这种问题的方法有如下工具:
1.forever
2.nodemon
3.supervisor nodeclub 使用 forever 来运行项目, 使用这类工具的好处就是, 当有代码改动过后, 会自动的重启应用。 不必每次自己去运行 node *.js