简单分析一个真实的项目-nodeclub,这个项目是node社区的源码,可以看做是一个用Node.js(Express框架)实现的社区论坛的模板。建议参考下面的技术栈目录上一篇文章中的知识图谱学习。

包含的功能:

  1. 登录&&注册&&验证
    1. 通过第三方github信息注册
    2. 直接注册
  2. 账户系统
  3. 发帖,评论
    1. 图片上传
    2. markdown
  4. 站内搜索
  5. 日志、性能、监控
  6. 安全
  7. 其他功能
    1. rss

使用的技术栈:

  1. Node.js&&express
  2. 代码设计结构--mvc
    1. m
      1. 数据持久化
        1. 数据库方案:mongodb文档数据库
        2. orm框架:mongoose模块
        3. cookies:cookie-parser模块
      2. 内存数据存储
        1. session&&redis—express-session,connect-redis模块
    2. v
      1. bootstrap框架
      2. 渲染方式&&模板引擎ejs-mate模块
      3. app.locals res.locals传值
      4. loader加载css,js,less
      5. 混合使用css与less配置样式
    3. c
      1. 路由方案—express.Router();
      2. 代码流程控制,eventproxy,解决回调地狱。
  3. 工具lib
    1. html解析中间件 body-parse
    2. 解决js回调问题 — eventproxy模块
    3. http请求模拟superagent模块:测试api,代理,爬虫等
    4. 上传文件 busboy
    5. uuid生成工具—uuid的作用与场景
    6. nodejs的后端字符串验证器-validator
    7. url解析库—nodejs自带的
    8. method-override
    9. moment模块:处理时间相关操作。
  4. 登录解决方案,token技术
    1. passport node的登录认证中间件,支持用户名密码登录或者oauth登录
    2. passport-github passport的github插件,实现github的登录策略
  5. 安全
    1. 全站https
    2. 加解密
      1. bcrypt模块
      2. 数据库不存明文,只存加盐后的值或者token
      3. cookies加盐签名,sessionId加盐签名
    3. csrf问题:csurf模块
    4. 输入校验
    5. helmet模块
  6. 性能优化
    1. 上线前开启production模式
    2. 使用gzip
    3. js,css文件压缩—Loader模块
    4. 视图缓存(production会启用):app.set('view cache', true);
    5. api接口限流—limit.js自定义的中间件
  7. 监控
    1. 日志系统log4js
    2. 输出请求参数,ip,耗时
    3. 错误日志输入
    4. 第三方性能监控工具:oneapm
  8. 其他
    1. 支持api json接口的跨域访问—cors模块,
    2. 反向代理(nginx)背后 —app.enable('trust proxy');

项目分析

目录结构

调用流程

nodeclub数据流动

注意一下,操作model这里有点乱,很多地方都操作了,如proxy,common中的一写工具,controllers下的控制器直接操作model。api目录下的RestfulAPI也有直接操作的。理想情况下:所有的model操作都在proxy中。controller api common中都引用proxy操作。问题是:比较麻烦,很多简单操作要多封装一层。

View层界面相关技术

app.locals这个对象字面量中定义的键值对,是可以直接在模板中使用的,就和res.render时开发者传入的模板渲染参数一样

模板的值传递

locals可能存在于app对象中即:app.locals;也可能存在于res对象中,即:res.locals。两者都会将该对象传递至所渲染的页面中。不同的是,app.locals会在整个生命周期中起作用;而res.locals只会有当前请求中起作用。由于app.locals在当前应用所有的渲染模中访问,这样我们就可以在该对象中定义一些顶级/全局的数据,并在渲染模板中使用。

ejs

直接使用ejs的include支持模板复用:https://cnodejs.org/topic/50c1a0ed637ffa4155d05256

bootstrap前端样式框架

css与less样式表

Less是CSS样式表的一个进化版本,支持变量的定义。需要注意的是less最终会转换成css文件:

Controller层分析

controller主要由路由调用,由它去操作model(通过proxy)调用工具方法处理数据,包含了大部分业务代码,最后render view输出。因此controller比较重。按功能模块分为不同js,每个js中暴露多个控制器。

代码主要

Model层分析

nodeclub使用Mongodb做数据库,存储用户和帖子信息。

Cookies存储

// cookie-parser中间件
app.use(require('cookie-parser')(config.session_secret));
// write
var opts = {
    path: '/',
    maxAge: 1000 * 60 * 60 * 24 * 30,
    signed: true,
    httpOnly: true
  };
res.cookie(config.auth_cookie_name, auth_token, opts);
// clear
res.clearCookie(config.auth_cookie_name, { path: '/' });
// read
var auth_token = req.signedCookies[config.auth_cookie_name];

session&&redis

登录模块分析

登录模块基本包含了应用的所有技术,是一个不错的分析点。

第三方登录与组成

第三方授权成功后分为三种情况:

基本流程:

  1. 通过oath2.0获得github的授权,获得accessToken

    // oauth 中间件
    app.use(passport.initialize());
    // github oauth
    passport.serializeUser(function (user, done) {
      done(null, user);
    });
    passport.deserializeUser(function (user, done) {
      done(null, user);
    });
    // 注册了github的auth登录策略(passport-github)
    passport.use(new GitHubStrategy(config.GITHUB_OAUTH, githubStrategyMiddleware));
    // github登录的路由,入口,调用了github授权
    router.get('/auth/github', configMiddleware.github, passport.authenticate('github'));
    // oauth2.0的回调路径,见oAuth的原理,最终获得accessToken,回调github.callback方法(自定义方法)
    router.get('/auth/github/callback',
      passport.authenticate('github', { failureRedirect: '/signin' }), github.callback);
    

  2. 使用accessToken访问github的帐号信息,获得email(必须要有email,否则提示错误):这一步包含在 passport.authenticate中,github.callback回调中已经有这些值了。保存在req.user中。

  3. 通过github查询是否该github帐号已经注册过,有则更新数据库信息(accessToken等)然后直接登录,没有跳转到新帐号创建界面。

    思考:

    • 为什么非首次可以直接登录成功?因为在这里已经拿到accessToken,说明用户已经授权,以前也登录过。
    • 为什么首次授权成功后要跳转?这里的新用户界面给用户选择是否绑定以前的帐号。。如果没有这个需求可以直接创建账户然后登录成功。
    exports.callback = function (req, res, next) {
      var profile = req.user;
      var email = profile.emails && profile.emails[0] && profile.emails[0].value;
      // 查询数据库
      User.findOne({githubId: profile.id}, function (err, user) {
        if (err) {
          return next(err);
        }
        // 非首次授权,已经是 cnode 用户时,更新他的资料
        if (user) {
          user.githubUsername = profile.username;
          user.githubId = profile.id;
          user.githubAccessToken = profile.accessToken;
          // user.loginname = profile.username;
          user.avatar = profile._json.avatar_url;
          user.email = email || user.email;
    	  // 保存数据到数据库
          user.save(function (err) {
            if (err) {
             // 错误处理 略。。。。
              return next(err);
            }
            // 登录成功!!!在res中生成auth_token,放入cookies,其中auth_token是user对象的_id,即数据库中的主id
            authMiddleWare.gen_session(user, res);
            // 回主页
            return res.redirect('/');
          });
        } else {
          // 首次授权,用户还未存在,则建立新用户,重定向网页
          req.session.profile = profile;
          return res.redirect('/auth/github/new');
        }
      });
    };
    
    function gen_session(user, res) {
      var auth_token = user._id + '$$'; // 以后可能会存储更多信息,用 $$ 来分隔
      var opts = {
        path: '/',
        maxAge: 1000 * 60 * 60 * 24 * 30,
        signed: true,
        httpOnly: true
      };
      res.cookie(config.auth_cookie_name, auth_token, opts); //cookie 有效期30天
    }
    
  4. 新用户流程:添加数据库数据,生成sessionId返回给用户

    router.get('/auth/github/new', github.new);
    // 新用户返回这个界面,其中有两个表单,1个是创建新用户,另一个是绑定以前的帐号。action都是调转到/auth/github/create,提交的表单有个isNew的标志,区分用户提交的哪个表单。
    exports.new = function (req, res, next) {
      res.render('sign/new_oauth', {actionPath: '/auth/github/create'});
    };
    

    表单界面布局代码:

   <!--第一个表单:注意csrf isnew 两个hidden -->      
   <form id='signin_form' class='form-horizontal' action=<%= actionPath%> method='post'>
         <input type='hidden' name='_csrf' value='<%= csrf %>'/>
         <input type='hidden' name='isnew' value='1'/>

         <div class='control-group'>
           <label class='control-label'>通过 GitHub 帐号</label>

           <div class='controls'>
             <input type='submit' class='span-info' value="注册新账号">
           </div>
         </div>
   </form>
         
   <!--第二个表单 注意csrf 还有密码的处理-->
   <form id='signin_form' class='form-horizontal' action=<%= actionPath%> method='post'>
         <div class='control-group'>
           <label class='controls'>或者</label>
         </div>
         <div class='control-group'>
           <label class='control-label' for='name'>用户名</label>

           <div class='controls'>
             <input class='input-xlarge' id='name' name='name' size='30' type='text'/>
           </div>
         </div>
         <div class='control-group'>
           <label class='control-label' for='pass'>密码</label>
           <div class='controls'>
             <input class='input-xlarge' id='pass' name='pass' size='30' type='password'/>
           </div>
         </div>
         <input type='hidden' name='_csrf' value='<%= csrf%>'/>
         <div class='form-actions'>
           <input type='submit' class='span-primary' value='关联旧账号'/>
         </div>
   </form>
      

创建帐号/绑定github核心代码

   router.post('/auth/github/create', github.create);

   exports.create = function (req, res, next) {
     var profile = req.session.profile;

     var isnew = req.body.isnew;
     var loginname = validator.trim(req.body.name || '').toLowerCase();
     var password = validator.trim(req.body.pass || '');
     var ep = new eventproxy();
     ep.fail(next);

     if (!profile) {
       return res.redirect('/signin');
     }
     delete req.session.profile;

     var email = profile.emails && profile.emails[0] && profile.emails[0].value;
     if (isnew) { // 注册新账号
       // 创建新的数据库实体
       var user = new User({
         loginname: profile.username,
         pass: profile.accessToken, //github用户密码就是accessToken
         email: email,
         avatar: profile._json.avatar_url,
         githubId: profile.id, // githubId,一个重要标志符
         githubUsername: profile.username,
         githubAccessToken: profile.accessToken,//github的token
         active: true,
         accessToken: uuid.v4(),// uuid生成,这么名字不好
       });
       user.save(function (err) {
         if (err) {
           // 错误处理。。。如重复的email,loginname等
           return next(err);
         }
         // 登录成功!!!
         authMiddleWare.gen_session(user, res);
         res.redirect('/');
       });
     } else { // 关联老账号
       ep.on('login_error', function (login_error) {
         res.status(403);
         res.render('sign/signin', { error: '账号名或密码错误。' });
       });
       User.findOne({loginname: loginname},
         ep.done(function (user) {
           if (!user) {
             return ep.emit('login_error');
           }
         // 密码校验
           tools.bcompare(password, user.pass, ep.done(function (bool) {
             if (!bool) {
               return ep.emit('login_error');
             }
             // 更新github相关信息,绑定帐号
             user.githubUsername = profile.username;
             user.githubId = profile.id;
             // user.loginname = profile.username;
             user.avatar = profile._json.avatar_url;
             user.githubAccessToken = profile.accessToken;

             user.save(function (err) {
               if (err) {
                 return next(err);
               }
               // 登录成功!!!
               authMiddleWare.gen_session(user, res);
               res.redirect('/');
             });
           }));
         }));
     }
   };
  1. 登录成功:登录成功后页面会重新跳转,发出新的http请求,在app.js中的中间件会更具cookies中的auth_token查询用户信息,并放入 res.locals.current_user req.session.user中。前者用于界面渲染,后者用于session。

    exports.authUser = function (req, res, next) {
      var ep = new eventproxy();
      ep.fail(next);
    
      // Ensure current_user always has defined.
      res.locals.current_user = null;
    
      ep.all('get_user', function (user) {
        if (!user) {
          return next();
        }
        user = res.locals.current_user = req.session.user = new UserModel(user);
    
        if (config.admins.hasOwnProperty(user.loginname)) {
          user.is_admin = true;
        }
    
        Message.getMessagesCount(user._id, ep.done(function (count) {
          user.messages_count = count;
          next();
        }));
      });
      // 第一步:查看session是否有值,有直接使用它
      if (req.session.user) {
        ep.emit('get_user', req.session.user);
      } else {
        // 第二步,如果没有session,则获取auth_token
        var auth_token = req.signedCookies[config.auth_cookie_name];
        // 没有auth_token,说明没有登录
        if (!auth_token) {
          return next();
        }
        // 有auth_token,没session,首次登录,查询数据库。
        var auth = auth_token.split('$$');
        var user_id = auth[0];
        UserProxy.getUserById(user_id, ep.done('get_user'));
      }
    };
    

  2. 总结:

    1. 传输安全性:,表单的post提交本身没有加密,安全是通过https保证的。如果不使用https,建议提交时用js加密处理

    2. 数据库安全性:数据库中存储的密码是加盐后的,没有存储明文(使用bcrypt模块)

      简单介绍bcrypt的两方法

      • bcrypt.hash(source_string,salt_length,callback):第一个参数是密码明文,第二个参数是盐的长度,salt有时钟tick产生,然后会用salt计算一个hash值xxxx,然后拼接上saltvalue,最终生成xxxxsaltvalue。存入数据库。
      • bcrypt.compare(sourceString,hash,callback),第一个参数是用户输入的密码,第二个是数据库存的xxxxsaltvalue,这个函数会取出xxxx和saltvalue,用saltvalue与sourceString计算的值比较xxxx,来验证密码输入是否正确。
      • 思考:为什么xxxxsaltvalue存在数据库中是安全的,可以防止脱库?因为脱裤的原理是:计算常用的密码的md5形成字典,然后比较。但是这里每个密码的salt都不一样,没法生成字典,所以无法脱库。
    3. csrf:校验过程参考这篇文章

    4. 字符串校验部分是用的validator库

    5. 这里存了uuid,但是没有用,uuid生成是专门的库

    6. 生成auth_token,是用的数据库的_id保证唯一性,后续也方便查询数据库。过期时间是30天。思考:为什么要这样,为什么不直接把数据放入session中???

      这个是token与session的区别!,sessionId与token都存在cookies中,但是sessionId代表了内存中的一段数据,是服务端用来缓存会话的。token是登录凭证,表示登录成功过。

      • 如果只有session,没有token:用户一旦登录,信息直接存储在session中,可以使用。但是一旦session丢失(主要指服务端重启,redis清空。客户端清空cookies,SessionId没了token也没了),用户需要重新登录。
      • 只有token,没有session:用户登录后token放入cookies。由于没有session,每次用户请求都带着token,必须每一次都查询数据库
      • 既有token也有session:服务端session丢失,只会用token重新查找数据库,恢复到session中。但是清空cookies依然会导致信息丢失。
      • 当然,也可以用token作为sessionId,那么在cookies中只要存一个值就可以了。但是最好不要这样,职责清晰。

原生注册与登录

注册基本流程:

  1. post提交表单:信息需要用户名密码,邮件,未加密(有https保证安全)直接post提交,client未校验,全部由服务端校验

    <form id='signup_form' class='form-horizontal' action='/signup' method='post'>
            <div class='control-group'>
              <label class='control-label' for='loginname'>用户名</label>
    
              <div class='controls'>
                <% if (typeof(loginname) !== 'undefined') { %>
                <input class='input-xlarge' id='loginname' name='loginname' size='30' type='text' value='<%= loginname %>'/>
                <% } else { %>
                <input class='input-xlarge' id='loginname' name='loginname' size='30' type='text' value=''/>
                <% } %>
              </div>
            </div>
            <div class='control-group'>
              <label class='control-label' for='pass'>密码</label>
    
              <div class='controls'>
                <input class='input-xlarge' id='pass' name='pass' size='30' type='password'/>
              </div>
            </div>
            <div class='control-group'>
              <label class='control-label' for='re_pass'>确认密码</label>
    
              <div class='controls'>
                <input class='input-xlarge' id='re_pass' name='re_pass' size='30' type='password'/>
              </div>
            </div>
            <div class='control-group'>
              <label class='control-label' for='email'>电子邮箱</label>
    
              <div class='controls'>
                <% if (typeof(email) !== 'undefined') { %>
                <input class='input-xlarge' id='email' name='email' size='30' type='text' value='<%= email %>'/>
                <% } else { %>
                <input class='input-xlarge' id='email' name='email' size='30' type='text'/>
                <% } %>
              </div>
            </div>
            <input type='hidden' name='_csrf' value='<%= csrf %>'/>
    
            <div class='form-actions'>
              <input type='submit' class='span-primary' value='注册'/>
              <a href="/auth/github">
                <span class="span-info">
                  通过 GitHub 登录
                </span>
              </a>
            </div>
     </form>
    
  2. 处理post请求:注意路由中是post

    exports.signup = function (req, res, next) {
      var loginname = validator.trim(req.body.loginname).toLowerCase();
      var email     = validator.trim(req.body.email).toLowerCase();
      var pass      = validator.trim(req.body.pass);
      var rePass    = validator.trim(req.body.re_pass);
    
      var ep = new eventproxy();
      ep.fail(next);
      ep.on('prop_err', function (msg) {
        res.status(422);
        res.render('sign/signup', {error: msg, loginname: loginname, email: email});
      });
    
      // 服务端验证信息的正确性
      if ([loginname, pass, rePass, email].some(function (item) { return item === ''; })) {
        ep.emit('prop_err', '信息不完整。');
        return;
      }
      if (loginname.length < 5) {
        ep.emit('prop_err', '用户名至少需要5个字符。');
        return;
      }
      if (!tools.validateId(loginname)) {
        return ep.emit('prop_err', '用户名不合法。');
      }
      if (!validator.isEmail(email)) {
        return ep.emit('prop_err', '邮箱不合法。');
      }
      if (pass !== rePass) {
        return ep.emit('prop_err', '两次密码输入不一致。');
      }
      // END 验证信息的正确性
    
      // mongo数据库查询or语句,是否用户名或者email占用
      User.getUsersByQuery({'$or': [
        {'loginname': loginname},
        {'email': email}
      ]}, {}, function (err, users) {
        if (err) {
          return next(err);
        }
        if (users.length > 0) {
          ep.emit('prop_err', '用户名或邮箱已被使用。');
          return;
        }
    	// 可以注册!!!!
        // 生成密码的hash值!不保存密码原文。
        tools.bhash(pass, ep.done(function (passhash) {
          // create gravatar
          var avatarUrl = User.makeGravatar(email);
          // 数据库操作
          User.newAndSave(loginname, loginname, passhash, email, avatarUrl, false, function (err) {
            if (err) {
              return next(err);
            }
            // 发送激活邮件
            mail.sendActiveMail(email, utility.md5(email + passhash + config.session_secret), loginname);
            res.render('sign/signup', {
              success: '欢迎加入 ' + config.name + '!我们已给您的注册邮箱发送了一封邮件,请点击里面的链接来激活您的帐号。'
            });
          });
    
        }));
      });
    };
    

    分析:

    • 首先对输入进行校验,
    • 查询是否已经注册,如果有,则提示用户
    • 如果没有,计算密码,hash。只保存加盐的值,不保存原文。
    • 发送用户激活邮件到邮箱,邮箱中包含一个连接其中有token和loginname。token=md5(passhash+serectkey+email)计算出(组成随意,能标识用户即可)。用户点击链接会调用服务器api,传回值,我们依据loginname查找用户,然后按照规则计算对比即可。
    • 注册完成,激活完成后,需要登录。

登录基本流程

  1. 提交表单。。略,与上面类似。

  2. 处理表单

    exports.login = function (req, res, next) {
      // 输入校验
      var loginname = validator.trim(req.body.name).toLowerCase();
      var pass      = validator.trim(req.body.pass);
      var ep        = new eventproxy();
    
      ep.fail(next);
    
      if (!loginname || !pass) {
        res.status(422);
        return res.render('sign/signin', { error: '信息不完整。' });
      }
    
      // 判断是用户名还是邮箱登录
      var getUser;
      if (loginname.indexOf('@') !== -1) {
        getUser = User.getUserByMail;
      } else {
        getUser = User.getUserByLoginName;
      }
    
      ep.on('login_error', function (login_error) {
        res.status(403);
        res.render('sign/signin', { error: '用户名或密码错误' });
      });
     // 查询数据库
      getUser(loginname, function (err, user) {
        if (err) {
          return next(err);
        }
        if (!user) {
          return ep.emit('login_error');
        }
        var passhash = user.pass;
        // 密码对比,与注册相互呼应,参考第三方登录的总结一节
        tools.bcompare(pass, passhash, ep.done(function (bool) {
          if (!bool) {
            return ep.emit('login_error');
          }
          if (!user.active) {
            // 重新发送激活邮件
            mail.sendActiveMail(user.email, utility.md5(user.email + passhash + config.session_secret), user.loginname);
            res.status(403);
            return res.render('sign/signin', { error: '此帐号还没有被激活,激活链接已发送到 ' + user.email + ' 邮箱,请查收。' });
          }
          // 生成auth_token,与第三方登录类似
          authMiddleWare.gen_session(user, res);
          // 登录完成的跳转,通过referrer获得来源
          var refer = req.session._loginReferer || '/';
          for (var i = 0, len = notJump.length; i !== len; ++i) {
            if (refer.indexOf(notJump[i]) >= 0) {
              refer = '/';
              break;
            }
          }
          res.redirect(refer);
        }));
      });
    };
    

    分析:

    • 输入校验
    • 判断是用户名还是邮箱登录,设置相应的查找方法
    • 数据库查询,对比密码参考第三方登录的总结一节的数据库安全
    • 生成auth_token,参考第三方登录。
    • 登录完成的跳转redirect。referrer处理,referrer中记载了跳转的源页面,这里过滤了一些不用跳转的页面。关于什么是referrerreq.session._loginReferer值在登录页面的请求中有赋值 req.session._loginReferer =req.headers.referer;
    • 登录完成,参考第三方登录过程。

HTTP请求类型分析

按照开发的常用情况把所有http请求分为以下几种:

其他

文件上传

测试

uuid

安全

监控

处理跨域访问

暴露的API接口(api/v1/xxx)使用cors模块处理跨域访问的问题:

部署问题处理

一些缺乏的

总结

至此,Node.js部分学习完成,这是我第一次分析一个完整的后端项目,nodeclub是个很好的demo,