让用户不在匿名

添加登录功能

为了简化登录功能,我们要求用户输入邮箱地址即可登录,通过邮箱获取用户的avatar头像,作为用户头像显示在消息中。

在此之前,为了便于维护,我们先简单重构一下前端代码,把technode.js中的各个组件拆分出来,放到对应的文件中。拆了之后static的目录结构如下:

static
├── components
│   ├── angular
│   ├── bootstrap
│   └── jquery
├── controllers
│   ├── message-creator.js
│   └── room.js
├── directives
│   ├── auto-scroll-to-bottom.js
│   └── ctrl-enter-break-line.js
├── index.html
├── services
│   └── socket.js
├── styles
│   └── room.css
└── technode.js

将js文件,按照顺序引入到index.html文件中:

<script type="text/javascript" src="/technode.js"></script>
<script type="text/javascript" src="/services/socket.js"></script>
<script type="text/javascript" src="/directives/auto-scroll-to-bottom.js"></script>
<script type="text/javascript" src="/directives/ctrl-enter-break-line.js"></script>
<script type="text/javascript" src="/controllers/room.js"></script>
<script type="text/javascript" src="/controllers/message-creator.js"></script>

接下来,使用Angular的router组件,将TechNode分成两个页面:

首先,在TechNode目录下使用bower install angular-route --save安装angular-router。安装好之后,修改index.html,引入angular-route:

<script type="text/javascript" src="/components/angular/angular.js"></script>
<script type="text/javascript" src="/components/angular-route/angular-route.js"></script>

angular-route提供了强大的路由功能,SPA(单页面应用程序)与传统网页的区别点之一就是,SPA将路由从后端服务器移到了客户端,本身只有一个页面,但是使用angular-route之后,可以使得不同的URL地址对应不同的视图,看起来就像切换了页面一样。

在technode.js中申明对angular-route的依赖,引入router组件:

angular.module('techNodeApp', ['ngRoute'])

staitc目录下新建router.js,添加如下代码,:

angular.module('techNodeApp').config(function($routeProvider, $locationProvider) {
  $locationProvider.html5Mode(true)
  $routeProvider.
  when('/', {
    templateUrl: '/pages/room.html',
    controller: 'RoomCtrl'
  }).
  when('/login', {
    templateUrl: '/pages/login.html',
    controller: 'LoginCtrl'
  }).
  otherwise({
    redirectTo: '/login'
  })
})

$locationProvider.html5Mode(true)采用HTML5的pushState来实现路由;下一步把room组件对应的视图从index.html拆出来放到room.html中,供angular调用。在static目录下新建pages目录,添加room.html文件:

<div class="col-md-12">
  <div class="panel panel-default room">
    <div class="panel-heading room-header">TechNode</div>
    <div class="panel-body room-content">
      <div class="list-group messages" auto-scroll-to-bottom>
        <div class="list-group-item message" ng-repeat="message in messages">
          某某: {{message}}
        </div>
      </div>
      <form class="message-creator" ng-controller="MessageCreatorCtrl">
        <div class="form-group">
          <textarea required class="form-control message-input" ng-model="newMessage" ctrl-enter-break-line="createMessage()" placeholder="Ctrl+Enter to quick send"></textarea>
        </div>
      </form>
    </div>
  </div>
</div>

在前一章中,我们使用ng-controller="RoomCtrl"指令为房间视图绑定RoomCtrl控制器,房间组件拆分出来后,无需再为其指定控制器,因为在router.js中已经指定。

<div class="container" style="margin-top:100px;">
  <div class="row" ng-view></div>
</div>

在index.html中,使用ng-view指令放一个占位符,Angular可以根据URL地址的不同载入对应的视图和控制器;

最后,在pages目录中加入login.html视图,添加如下代码:

<form class="form-inline">
  <div class="form-group">
    <label class="sr-only">Gmail</label>
    <input type="email" required class="form-control" placeholder="Mail Account" />
  </div>
  <button type="submit" class="btn btn-primary btn-enter">Enter</button>
</form>

这个视图很简单,一个简单的表单,用户输入邮箱地址,点击按钮登录。

Login

用户登录与认证

单页面的应用程序意味着我们无法像传统的网页那样通过提交表单来实现登录验证,因此我们采用Ajax来实现。

Angular提供了一个名为Run Block的启动模块,即当整个应用启动时第一个执行的块。于是我们把登录验证逻辑写在这里:

angular.module('techNodeApp', ['ngRoute']).
run(function ($window, $rootScope, $http, $location) {
  $http({
    url: '/api/validate',
    method: 'GET'
  }).success(function (user) {
    $rootScope.me = user
    $location.path('/')
  }).error(function (data) {
    $location.path('/login')
  })
})

在technode.js中添加如上的代码,调用.run()方法,注入我们的启动模块。

$http是Angular提供的一个Ajax组件,在应用启动时,通过Ajax调用服务端的验证接口'/api/validate',获取用户的信息,如果用户已登录,服务端返回用户信息,客户端把用户信息保存到全局作用域中$rootScope.me中,然后通过$location组件跳转到/,即聊天室页面;如果用户未登录,则跳转到登录页。

登录验证API

我们使用MongoDB来存储用户信息,借助mongoose操作数据库。首先定义用于存储用户信息的Schema——User。在TechNode目录下新建models文件夹,加入user.js和index.js文件。

user.js:

var mongoose = require('mongoose')
var Schema = mongoose.Schema

var User = new Schema({
  email: String,
  name: String,
  avatarUrl: String
});

module.exports = User

avatarUrl这个字段是根据用户给的邮箱地址计算出来的avatar头像地址;

index.js:

var mongoose = require('mongoose')
mongoose.connect('mongodb://localhost/technode')
exports.User = mongoose.model('User', require('./user'))

记得使用npm install mongoose --save安装mongoose;

接下来编写登录验证的逻辑,在TechNode目录下新建controllers文件夹,新建user.js文件,为了便于管理代码,我们把与用户相关的业务逻辑都放在controllers/user.js这个文件中;

var db = require('../models')
var async = require('async')
var gravatar = require('gravatar')

exports.findUserById = function (_userId, callback) {
  db.User.findOne({
    _id: _userId
  }, callback)
}

exports.findByEmailOrCreate = function (email, callback) {
  db.User.findOne({
    email: email
  }, function (err, user) {
    if (user) {
      callback(null, user)
    } else {
      user = new db.User
      user.name = email.split('@')[0]
      user.email = email
      user.avatarUrl = gravatar.url(email)
      user.save(callback)
    }
  })
}

我们提供了两个接口,一是通过用户ID查找用户;二是通过邮箱地址查找用户,如果没找到,就基于邮箱地址创建一个新账户,头像地址使用gravatar这个Node模块来生成。

最后在app.js中将登录验证的接口暴露出来:

// ...
var Controllers = require('./controllers')

app.use(express.bodyParser())
app.use(express.cookieParser())
app.use(express.session({
  secret: 'technode',
  cookie:{
    maxAge: 60 * 1000
  }
}))

// ...

app.get('/api/validate', function (req, res) {
  _userId = req.session._userId
  if (_userId) {
    Controllers.User.findUserById(_userId, function (err, user) {
      if (err) {
        res.json(401, {msg: err})
      } else {
        res.json(user)
      }
    })
  } else {
    res.json(401, null)
  }
})

app.post('/api/login', function (req, res) {
  email = req.body.email
  if (email) {
    Controllers.User.findByEmailOrCreate(email, function(err, user) {
      if (err) {
        res.json(500, {msg: err})
      } else {
        req.session._userId = user._id
        res.json(user)
      }
    })
  } else {
    res.json(403)
  }
})

app.get('/api/logout', function (req, res) {
  req.session._userId = null
  res.json(401)
})
// ...

我们使用express提供的session模块来管理用户的认证,整个认证过程如下:

至此服务端的登录验证已经完成,接下来给客户端加上登录验证的功能。

回到登录页面login.html:

<form class="form-inline" ng-submit="login()">
  <div class="form-group">
    <label class="sr-only">Gmail</label>
    <input type="email" required class="form-control" ng-model="email" placeholder="Mail Account" />
  </div>
  <button type="submit" class="btn btn-primary btn-enter">Enter</button>
</form>

添加ng-submit="login()",为表单提交绑定一个处理函数login,为邮箱地址输入框绑定一个数据模型email。接下来实现登录页面的控制器LoginCtrl,在static/controllers文件夹中添加login.js,添加如下代码:

angular.module('techNodeApp').controller('LoginCtrl', function($scope, $http, $location) {
  $scope.login = function () {
    $http({
      url: '/api/login',
      method: 'POST',
      data: {
        email: $scope.email
      }
    }).success(function (user) {
      $scope.$emit('login', user)
      $location.path('/')
    }).error(function (data) {
      $location.path('/login')
    })
  }
})

当用户输入了邮箱地址提交表单时,就调用LoginCtrl的login方法,调用服务端的api/login接口,传入绑定在输入框中的数据绑定email,登录成功就跳转到聊天室/$scope.$emit('login', user)是干什么用的呢?接下去你就会明白。

首先在页面导航的右上角,显示用户的头像和登出链接:

<nav class="collapse navbar-collapse" role="navigation">
  <ul class="nav navbar-nav navbar-right" ng-show="me">
    <li>
      <img ng-src="{{me.avatarUrl}}" title="{{me.name}}" class="img-rounded"/>
    </li>
    <li>
      <a href="" ng-click="logout()">Log out</a>
    </li>
  </ul>
</nav>

如果变量me不为空,即存在用户信息就显示用户头像和登出了解,否则什么也不显示;

最后再回过来看看启动模块:

angular.module('techNodeApp', ['ngRoute']).
run(function ($window, $rootScope, $http, $location) {
  $http({
    url: '/ajax/validate',
    method: 'GET'
  }).success(function (user) {
    $rootScope.me = user
    $location.path('/')
  }).error(function (data) {
    $location.path('/login')
  })
  $rootScope.logout = function() {
    $http({
      url: '/ajax/logout',
      method: 'GET'
    }).success(function () {
      $rootScope.me = null
      $location.path('/login')
    })
  }
  $rootScope.$on('login', function (evt, me) {
    $rootScope.me = me
  })
})

在启动模块中,处理进行用户验证,还提供了提供名为logout的控制器方法,当用户点击登出链接时,通过Ajax调用服务端的api/logout接口,清除会话中的用户ID,同时通过$scope.me = null清除客户端的用户信息,将页面跳转至登录页。除此之外启动模块还通过$rootScope.$on监听着来自子域LoginCtrl的login事件,即当用户通过登录页成功登录后,将用户信息发送给启动模块,更新$rootScope的用户信息;至于为什么需要这样做,这和Angular的Scope机制有关,大家可以查看相关资料了解。

Login-1

socket.io验证

处理提供HTTP的登录验证之外,我们还需要对socket请求进行登录验证;socket.io提供了认证的接口,只需简单配置一下即可。在app.js下添加以下两段代码:

// ...
var parseSignedCookie = require('connect').utils.parseSignedCookie
var MongoStore = require('connect-mongo')(express)
var Cookie = require('cookie')

var sessionStore = new MongoStore({
  url: 'mongodb://localhost/technode'
})

app.use(express.bodyParser())
app.use(express.cookieParser())
app.use(express.session({
  secret: 'technode',
  cookie: {
    maxAge: 60 * 1000 * 60
  },
  store: sessionStore
}))

// ...

var io = require('socket.io').listen(app.listen(port))

io.set('authorization', function(handshakeData, accept) {
  handshakeData.cookie = Cookie.parse(handshakeData.headers.cookie)
  var connectSid = handshakeData.cookie['connect.sid']
  connectSid = parseSignedCookie(connectSid, 'technode')

  if (connectSid) {
    sessionStore.get(connectSid, function(error, session) {
      if (error) {
        accept(error.message, false)
      } else {
        handshakeData.session = session
        if (session._userId) {
          accept(null, true)
        } else {
          accept('No login')
        }
      }
    })
  } else {
    accept('No session')
  }
})

使用express自带的session组件会有一个问题,即session数据都是存储在内存中的,服务器重启,这些数据就消失了,会导致需要用户重新登录。所以我们使用MongoStore把session数据存储到MongoDB中,将session数据固化下来:

var parseSignedCookie = require('connect').utils.parseSignedCookie
var MongoStore = require('connect-mongo')(express)
var Cookie = require('cookie')

var sessionStore = new MongoStore({
  url: 'mongodb://localhost/technode'
})

app.use(express.bodyParser())
app.use(express.cookieParser())
app.use(express.session({
  secret: 'technode',
  cookie: {
    maxAge: 60 * 1000 * 60
  },
  store: sessionStore
}))

我们通过io.set('authorization',callback)这个接口进行认证,手动解析了客户端的session数据,如果找到session且session中存在用户信息的话,认证成功,否则认证失败; 在这里引入了connnect和cookie两个类库,别忘了使用npm install connect-mongo cookie --save安装他们; 还有,我们把session的存储对象暴露了出来,我们才得以在socket认证的过程中手动解析出session。

到此我们用户认证完成了,用户可以通过邮箱地址登录,也可以退出,在用户登录后,我们可以拿到用户的信息,这样在用户发送消息时,我们就可以显示用户名啦!

显示用户名和在线用户列表

显示用户名

修改room.html中的模板,添加用户名和用户头像:

<div class="col-md-12">
  <div class="panel panel-default room">
    <div class="panel-heading room-header">TechNode</div>
    <div class="panel-body room-content">
      <div class="list-group messages" auto-scroll-to-bottom>
        <div class="list-group-item message" ng-repeat="message in messages">
          <img ng-src="{{message.creator.avatarUrl}}" title="{{message.creator.name}}" class="img-rounded"/>
          {{message.creator.name}}: {{message.message}}
        </div>
      </div>
      <form class="message-creator" ng-controller="MessageCreatorCtrl">
        <div class="form-group">
          <textarea required class="form-control message-input" ng-model="newMessage" ctrl-enter-break-line="createMessage()" placeholder="Ctrl+Enter to quick send"></textarea>
        </div>
      </form>
    </div>
  </div>
</div>

修改message-creator.js,在用户发送消息时,将用户信息一同发送给服务端:

angular.module('techNodeApp').controller('MessageCreatorCtrl', function($scope, socket) {
  $scope.createMessage = function () {
    socket.emit('messages.create', {
      message: $scope.newMessage,
      creator: $scope.me
    })
    $scope.newMessage = ''
  }
})

通过socket发送的消息不再是一个字符串,而是一个携带消息内容,和消息发送者信息的json对象;

重启服务器,登录发条消息,终于,TechNode的用户不再是匿名的了!

DisplayUserName

显示在线用户

如果能够看到哪些用户在聊天室里就好了,让我们实现这个功能吧!

我们将用户是否在线的状态存储在数据库中,因此需要扩展User Schema的字段:

var User = new Schema({
  email: String,
  name: String,
  avatarUrl: String,
  online: Boolean
});

在用户登录或者登出是修改用户的在线状态:

app.post('/api/login', function(req, res) {
  email = req.body.email
  if (email) {
    Controllers.User.findByEmailOrCreate(email, function(err, user) {
      if (err) {
        res.json(500, {
          msg: err
        })
      } else {
        req.session._userId = user._id
        Controllers.User.online(user._id, function (err, user) {
          if (err) {
            res.json(500, {
              msg: err
            })
          } else {
            res.json(user)
          }
        })
      }
    })
  } else {
    res.josn(403)
  }
})

app.get('/api/logout', function(req, res) {
  _userId = req.session._userId
  Controllers.User.offline(_userId, function (err, user) {
    if (err) {
      res.json(500, {
        msg: err
      })
    } else {
      res.json(200)
      delete req.session._userId
    }
  })
})

修改了login和logout这两个接口,在用户登录或者退出时更新用户的状态,online和offline这两个方法就比较简单了,修改controllers/user.js,添加下面两个接口:

exports.online = function(_userId, callback) {
  db.User.findOneAndUpdate({
    _id: _userId
  }, {
    $set: {
      online: true
    }
  }, callback)
}
exports.offline = function(_userId, callback) {
  db.User.findOneAndUpdate({
    _id: _userId
  }, {
    $set: {
      online: false
    }
  }, callback)
}

既然用户是否在线的状态已经存在数据库中了,那接下来将其读出来显示在聊天室的右侧就行啦!

首先修改房间列表的控制器RoomCtrl,从服务端读取的数据不但包括消息还有在线用户列表:

angular.module('techNodeApp').controller('RoomCtrl', function($scope, socket) {
  socket.on('roomData', function (room) {
    $scope.room = room
  })
  socket.on('messageAdded', function (message) {
    $scope.technode.messages.push(message)
  })
  socket.emit('getRoom')
})

我们把messages和users都放到room这个对象中,修改socket服务端,将在线用户列表和消息列表一道读出来发送给客户端:

io.sockets.on('connection', function(socket) {
  socket.on('getRoom', function() {
    Controllers.User.getOnlineUsers(function (err, users) {
      if (err) {
        socket.emit('err', {msg: err})
      } else {
        socket.emit('roomData', {users: users, messages: messages})
      }
    })
  })
  socket.on('createMessage', function(message) {
    messages.push(message)
    io.sockets.emit('messageAdded', message)
  })
})

别忘了在controllers/user.js中添加getOnlineUsers接口:

exports.getOnlineUsers = function(callback) {
  db.User.find({
    online: true
  }, callback)
}

接下来就是把在线用户列表在客户端render出来了,其实只需要修改房间视图room.html即可:

<div class="col-md-9">
  <div class="panel panel-default room">
    <div class="panel-heading room-header">TechNode</div>
    <div class="panel-body room-content">
      <div class="list-group messages" auto-scroll-to-bottom>
        <div class="list-group-item message" ng-repeat="message in room.messages">
          <img src="{{message.creator.avatarUrl}}" title="{{message.creator.name}}" class="img-rounded"/>{{message.creator.name}}: {{message.message}}
        </div>
      </div>
      <form class="message-creator" ng-controller="MessageCreatorCtrl">
        <div class="form-group">
          <textarea required class="form-control message-input" ng-model="newMessage" ctrl-enter-break-line="createMessage()" placeholder="Ctrl+Enter to quick send"></textarea>
        </div>
      </form>
    </div>
  </div>
</div>
<div class="col-md-3">
  <div class="panel panel-default user-list">
    <div class="panel-heading user-list-header">在线用户</div>
    <div class="panel-body user-list-content">
      <div class="list-group users">
        <div class="list-group-item user" ng-repeat="user in room.users">
          <img src="{{user.avatarUrl}}" title="{{user.name}}" class="img-rounded"/>{{user.name}}
        </div>
      </div>
    </div>
  </div>
</div>

没有特别的地方,如消息列表一样渲染即可。

TechNode是基于socket的,用户上下线的概念并不仅限于登录和登出,用户通过socket连上就是上线,用户socket断开就是离线了。我们来看看如何实现基于socket的用户上下线。

io.sockets.on('connection', function(socket) {
  _userId = socket.handshake.session._userId
  Controllers.User.online(_userId, function(err, user) {
    if (err) {
      socket.emit('err', {
        mesg: err
      })
    } else {
      socket.broadcast.emit('online', user)
    }
  })
  socket.on('disconnect', function() {
    Controllers.User.offline(_userId, function(err, user) {
      if (err) {
        socket.emit('err', {
          mesg: err
        })
      } else {
        socket.broadcast.emit('offline', user)
      }
    })
  });
  // ...
})

我们添加了socket连上和断开的处理,通知客户端有用户连上或者下线了。客户端只需监听这两个事件即可,在房间控制器RoomCtrl添加对这两个事件的处理:

socket.on('online', function (user) {
  $scope.room.users.push(user)
})
socket.on('offline', function (user) {
  _userId = user._id
  $scope.room.users = $scope.room.users.filter(function (user) {
    return user._id != _userId
  })
})

至此,本章最核心的部分已经完成,现在你可以在聊天室的右侧查看在线的用户的了。

DisplayUserList

消息持久化和系统消息

到目前为止,聊天室的消息并没有存放在数据库中,而且消息没有创建时间等等,现在我们就来处理这些问题。首先创建一个消息的Schema,在models中添加message.js,添加如下代码:

var mongoose = require('mongoose')
var Schema = mongoose.Schema,
  ObjectId = Schema.ObjectId

var Message = new Schema({
  content: String,
  creator: {
    _id: ObjectId,
    email: String,
    name: String,
    avatarUrl: String

  },
  createAt:{type: Date, default: Date.now}
})

module.exports = Message

在消息中,我们存放了消息的内容和创建时间,还将消息创建人的详细信息也包含在了消息里,这样做的好处是,浪费一点空间,缩短查询时的时间;因为消息中的creator数据并不需要保持绝对的一致性。

在models/index.js中,根据Schema生成Message模型:

// ...
exports.Message = mongoose.model('Message', require('./message'))

在服务端添加一个消息的控制器'controllers/message.js',实现消息的写入和查询:

var db = require('../models')

exports.create = function(message, callback) {
  var message = new db.Message()
  message.content = message.content
  message.creator = message.creator
  message.save(callback)
}
exports.read = function(callback) {
  db.Message.findAll({
  }, null, {
    sort: {
      'createAt': -1
    },
    limit: 20
  }, callback)
}

查询时,我们按照消息的创建的时间倒叙排列,而且取最新的20条;

接下来,我们修改与客户端通信的API部分:

socket.on('getRoom', function() {
  async.parallel([
    function(done) {
      Controllers.User.getOnlineUsers(done)
    },
    function(done) {
      Controllers.Messages.read(done)
    }
  ],
  function(err, results) {
    if (err) {
      socket.emit('err', {
        msg: err
      })
    } else {
      socket.emit('roomData', {
        users: results[0],
        messages: results[1]
      })
    }
  });
})
socket.on('createMessage', function(message) {
  Controllers.Message.create(function (err, message) {
    if (err) {
      socket.emit('err', {msg: err})
    } else {
      io.sockets.emit('messageAdded', message)
    }
  })
})

在这里我们使用async来并行地对数据库进行读取,别忘记了使用npm安装async包。

简单修改客户端的房间视图,显示消息发出的时间:

<div class="list-group-item message" ng-repeat="message in technode.messages">
  <img src="{{message.creator.avatarUrl}}" title="{{message.creator.name}}" class="img-rounded"/>{{message.creator.name}}: {{message.content}}<time am-time-ago="message.createAt"></time>
</div>

在这里我们使用了一个名为am-time-ago的Angular指令,这个指令可以动态地更新time标记中的时间显示,开始可能是几秒前,随着时间的推移,能够智能地便成1分钟前1小时前等等;这个指令是angular-moment这个模块提供的,请使用bower install angular-moment --save安装这个模块,并在将其加入到index.html中:

<script type="text/javascript" src="/components/moment/moment.js"></script>
<script type="text/javascript" src="/components/angular-moment/angular-moment.js"></script>
<script type="text/javascript" src="components/moment/lang/zh-cn.js"></script>

我们不但加入了angular-moment.js,还添加了一个中文语言包。需要在technode.js中添加对angular-moment.js的依赖,并将语言设置为中文:

angular.module('techNodeApp', ['ngRoute', 'angularMoment']).
run(function ($window) {
  $window.moment.lang('zh-cn')
  // ...
})

至此,我们将消息保存进了数据库,还保存了消息的创建时间,并在客户端将时间显示出来,我们将消息持久化的任务完成了。

系统消息

我们为用户提供了一些系统的消息,比如其他用户登录退出的信息等等;这部分消息都是临时的,因此我们不会把它们加入到数据库中,动态生成即可。

比如我们添加有用户登录和登出的消息:

io.sockets.on('connection', function(socket) {
  _userId = socket.handshake.session._userId
  Controllers.User.online(_userId, function(err, user) {
    if (err) {
      socket.emit('err', {
        mesg: err
      })
    } else {
      socket.broadcast.emit('online', user)
      socket.broadcast.emit('messageAdded', {
        content: user.name + '进入了聊天室',
        creator: SYSTEM,
        createAt: new Date()
      })
    }
  })
  socket.on('disconnect', function() {
    Controllers.User.offline(_userId, function(err, user) {
      if (err) {
        socket.emit('err', {
          mesg: err
        })
      } else {
        socket.broadcast.emit('offline', user)
        socket.broadcast.emit('messageAdded', {
          content: user.name + '离开了聊天室',
          creator: SYSTEM,
          createAt: new Date()
        })
      }
    })
  });

其实我们就是伪造了两条临时消息。

DisplayUserTime

我不喜欢你们!

我们的用户使用各种不同的技术,我们都不喜欢异类(使用与自己不同技术的人),他们需要自己的空间。下一章,我们要给TechNode加上房间的功能。这样臭味相投的人就可以聚到一起聊天啦!