提供不同的房间

在本章中,我们将为TechNode添加房间的功能,借助socket.io的room功能可以很快地实现,我们开始吧。

设计房间列表页面

我们为用户提供一个房间列表页面,在这个页面上用户可以看到所有的房间,并且可以搜索,如果没有搜索到的话,可以创建新的房间。

新增pages/rooms.html页面:

<div class="row">
  <div class="col-md-8 col-md-offset-2">
    <form>
      <div class="form-group">
        <label class="sr-only">房间名</label>
        <input type="input" required class="form-control search-room-input" placeholder="搜索房间" />
      </div>
    </form>
  </div>
</div>
<div class="row">
  <ul class="room-list clearfix">
    <li class="room-item">
      <div class="room-content">
        <h3>JavaScript<span class="badge pull-right">1人</span></h3>
        <div class="avatar-list">
            <span>
              <img alt="[email protected]" src="http://www.gravatar.com/avatar/dfd8ec3a8b02aea547de1b092557b97c" class="img-rounded"/>
            </span>
        </div>
      </div>
    </li>
  </ul>
</div>
<div class="row no-room">
    <form class="form-inline no-room-form">
      <div class="form-group">
        没找到你想要的房间<strong class="label label-default no-room-label">Java</strong>,<button class="btn btn-warning">点击新建</button>
      </div>
    </form>
</div>

这个页面分成三部分:

NewRoom

添加房间API

修改数据模型

因为我们加入了房间的概念,所以我们有必要修改一下数据模型:

首先添加房间的Schema,在models中添加room.js文件,添加如下代码:

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

var Room = new Schema({
  name: String,
  createAt:{type: Date, default: Date.now}
});

module.exports = Room

房间的数据模型很简单,只有两个字段,房间名称房间的创建时间;

修改消息的的Schema:

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
  },
  _roomId: ObjectId,
  createAt:{type: Date, default: Date.now}
})

module.exports = Message

添加了一个_roomId的作为指向房间的外间,让消息与房间对应起来。

同理为用户模型也添加一个:

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

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

module.exports = User

实现room的controller

我们需要两个接口:

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

exports.create = function(room, callback) {
  var r = new db.Room()
  r.name = room.name
  r.save(callback)
}

exports.read = function(callback) {
  db.Room.find({}, function(err, rooms) {
    if (!err) {
      var roomsData = []
      async.each(rooms, function(room, done) {
        var roomData = room.toObject()
        db.User.find({
          _roomId: roomData._id,
          online: true
        }, function(err, users) {
          if (err) {
            done(err)
          } else {
            roomData.users = users
            roomsData.push(roomData)
            done()
          }
        })
      }, function(err) {
        callback(err, roomsData)
      })
    }
  })
}

在读取房间信息时,我们使用async.each来并行地查询每个房间的用户列表。

提供socket的房间API

修改app.js的socket部分,为客户端提供两个接口——新建房间和读取所有房间列表:

socket.on('createRoom', function (room) {
  Controllers.Room.create(room, function (err, room) {
    if (err) {
      socket.emit('err', {msg: err})
    } else {
      io.sockets.emit('roomAdded', room)
    }
  })
})

sockets.on('getAllRooms', function () {
  Controllers.Room.read(function (err, rooms) {
    if (err) {
      socket.emit('err', {msg: err})
    } else {
      socket.emit('roomsData', rooms)
    }
  })
})

登录后跳转至房间列表

在上一章中,用户登录成功后,我们直接跳转至聊天室,但是现在我们有了房间的功能,因此当用户登录成功后,首先跳转至聊天室列表。在router.js中添加上房间列表的路由/rooms

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

在static/controllers下添加rooms.js,实现房间列表控制器RoomsCtrl:

angular.module('techNodeApp').controller('RoomsCtrl', function($scope) {
  // Nothing
})

目前这个控制器没有任何逻辑,稍后再来实现。

修改technode.js中的启动模块和登录控制器loginCtrl,当用户成功登录后,跳至房间列表:

// controllers/main.js
// ...
$http({
  url: '/ajax/validate',
  method: 'GET'
}).success(function (user) {
  $scope.me = user
  $location.path('/rooms')
}).error(function (data) {
  $location.path('/login')
})
// ...

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

房间列表

我们的目标就是把房间从服务器端读取过来,显示在房间列表页面上,首先在room.html添加Angular的绑定:

<div class="row">
  <div class="col-md-8 col-md-offset-2">
    <form>
      <div class="form-group">
        <label class="sr-only">房间名</label>
        <input type="input" required class="form-control search-room-input" ng-change="searchRoom()" ng-model="searchKey" placeholder="搜索房间" />
      </div>
    </form>
  </div>
</div>
<div class="row">
  <ul class="room-list clearfix">
    <li  ng-repeat="room in rooms" class="room-item">
      <div class="room-content" ng-click="enterRoom(room)">
        <h3>{{room.name}}<span class="badge pull-right">{{room.users.length}}</span>人</h3>
        <div class="avatar-list">
            <span ng-repeat="user in room.users" title="{{user.name}}">
              <img alt="{{user.email}}" src="{{user.avatarUrl}}" class="img-rounded"/>
            </span>
        </div>
      </div>
    </li>
  </ul>
</div>
<div class="row no-room" ng-show="rooms.length == 0 && searchKey">
    <form class="form-inline no-room-form">
      <div class="form-group">
        没找到你想要的房间<strong class="label label-default no-room-label">{{searchKey}}</strong>,<button class="btn btn-warning" ng-click="createRoom()">点击新建</button>
      </div>
    </form>
</div>

来看一下在这个模板视图中使用的绑定:

接下来实现RoomsCtrl:

angular.module('techNodeApp').controller('RoomsCtrl', function($scope, socket) {
  socket.emit('getAllRooms')
  socket.on('roomsData', function (rooms) {
    $scope.rooms = $scope._rooms = rooms
  })
  $scope.searchRoom = function () {
    if ($scope.searchKey) {
      $scope.rooms = $scope._rooms.filter(function (room) {
        return room.name.indexOf($scope.searchKey) > -1
      })
    } else {
      $scope.rooms = $scope._rooms
    }

  }
  $scope.createRoom = function () {
    socket.emit('createRoom', {
      name: $scope.searchKey
    })
  }
  socket.on('roomAdded', function (room) {
    $scope._rooms.push(room)
    $scope.searchRoom()
  })
})

首先我们通过socket.emit('getAllRooms')向服务端发起读取房间列表的请求,当服务端将房间返回后,我们将原始数据存储在_rooms中,rooms则拷贝一份;

$scope.searchRoom = function () {
  if ($scope.searchKey) {
    $scope.rooms = $scope._rooms.filter(function (room) {
      return room.name.indexOf($scope.searchKey) > -1
    })
  } else {
    $scope.rooms = $scope._rooms
  }
}

$scope.searchRoom则是过滤rooms的实现,我们仅仅做了简单的字符串包含的匹配;这也正式我们把原始数据保存在_rooms中的原因。

$scope.createRoom = function () {
  socket.emit('createRoom', {
    name: $scope.searchKey
  })
}
socket.on('roomAdded', function (room) {
  $scope._rooms.push(room)
  $scope.searchRoom()
})

createRoom通过调用服务端的接口创建房间,房间创建完成,服务端会触发一个roomAdded的事件,我们将新的房间加入到_rooms中,调用$scope.searchRoom()手动进行一次搜索,将新增的房间同步到rooms中。

由于Angular.js动态绑定的特性,随着rooms的变化,房间列表视图自动更新,我们无需手动操作DOM节点,维护DOM节点的状态,这就是Angular最鲜明的特点之一。

RoomList

接下来我们实现用户进入房间的逻辑。

进入单独的房间

在房间列表页面,当用户点击房间时,就跳转到对应的房间页面,但是在这之前,我们需要先与服务器通信,将用户的这个加入房间的动作发送到其他客户端。

angular.module('techNodeApp').controller('RoomsCtrl', function($scope, $location, socket) {
  // ...
  $scope.enterRoom = function (room) {
    socket.emit('joinRoom', {
      user: $scope.me,
      room: room
    })
  }
  socket.once('joinRoom.' + $scope.me._id, function (join) {
    $location.path('/rooms/' + join.room._id)
  })
  socket.on('joinRoom', function (join) {
    $scope.rooms.forEach(function (room) {
      if (room._id == join.room._id) {
        room.users.push(join.user)
      }
    })
  })
  // ...
})

$scope.enterRoom是与rooms.html中的房间绑定的,<div class="room-content" ng-click="enterRoom(room)">。当服务端处理完用户进入房间的动作之后,会向客户端发送一个joinRoom.52b380a837a4f24736000001的事件,即对应之前客户端发送的那个加入房间的请求。客户端收到这个事件之后,就跳转到特定的聊天室去了。

在这个控制器中,还监听了一个事件,joinRoom,即当有用户加入到某个房间后,服务端都广播这个事件,便于客户端更新在房间里的用户。

来看看服务端是如何实现的:

user的controller提供一个接口:

exports.joinRoom = function (join, callback) {
  db.User.findOneAndUpdate({
    _id: join.user._id
  }, {
    $set: {
      online: true,
      _roomId: join.room._id
    }
  }, callback)
}

修改app.js,用户加入某个房间后,将加入的房间ID保存到用户数据中。

socket.on('joinRoom', function(join) {
  Controllers.User.joinRoom(join, function(err) {
    if (err) {
      socket.emit('err', {
        msg: err
      })
    } else {
      socket.join(join.room._id)
      socket.emit('joinRoom.' + join.user._id, join)
      socket.in(join.room._id).broadcast.emit('messageAdded', {
        content: join.user.name + '进入了聊天室',
        creator: SYSTEM,
        createAt: new Date(),
        _id: ObjectId()
      })
      socket.in(join.room._id).broadcast.emit('joinRoom', join)
    }
  })
})

socket层的处理:当将用户加入动作写入数据库之后,首先调用socket.join方法,将当前的socket加入到一个键值为join.room._id的房间中,然后触发了三个事件:

接下来就是进入到具体的房间了:

别忘了本章开始对客户端router.js的修改:

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

聊天室不再是像第二章那样直接指向/,而是/rooms/:_roomId,在RoomCtrl中我们可以通过$routeParams来获得_roomId,根据这个id,我们就可以从服务端读取相关的房间数据,将聊天室渲染出来了。

angular.module('techNodeApp').controller('RoomCtrl', function($scope, $routeParams, $scope, socket) {
  socket.emit('getAllRooms', {
    _roomId: $routeParams._roomId
  })
  socket.on('roomData.' + $routeParams._roomId, function(room) {
    $scope.room = room
  })
  socket.on('messageAdded', function(message) {
    $scope.room.messages.push(message)
  })
  socket.on('joinRoom', function (join) {
    $scope.room.users.push(join.user)
  })
})

首先修改RoomCtrl,通过$routeParams获取route参数,触发getRoom事件,到服务器端读取房间数据;其次是接受新的消息,并当用户加入到房间时,将用户加入到用户列表中。

<div class="col-md-9">
  <div class="panel panel-default room">
    <div class="panel-heading room-header">{{room.name}}</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.content}}<time am-time-ago="message.createAt"></time>
        </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 发送"></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>

修改room.html页面,与第二章的基本一致,只是绑定的数据不一样了而已。将TechNode绑定为房间名。

接下来实现服务器端:

socket.on('getAllRooms', function(data) {
  if (data && data._roomId) {
    Controllers.Room.getById(data._roomId, function(err, room) {
      if (err) {
        socket.emit('err', {
          msg: err
        })
      } else {
        socket.emit('roomData.' + data._roomId, room)
      }
    })
  } else {
    Controllers.Room.read(function(err, rooms) {
      if (err) {
        socket.emit('err', {
          msg: err
        })
      } else {
        socket.emit('roomsData', rooms)
      }
    })
  }
})

修改getAllRooms的socket响应,如果客户端的请求数据中包含_roomId的话,就读取单独房间的数据,而不是读取所有的房间。

room的controller则添加需要的getById方法:

exports.getById = function(_roomId, callback) {
  db.Room.findOne({
    _id: _roomId
  }, function(err, room) {
    if (err) {
      callback(err)
    } else {
      async.parallel([

          function(done) {
            db.User.find({
              _roomId: _roomId,
              online: true
            }, function(err, users) {
              done(err, users)
            })
          },
          function(done) {
            db.Message.find({
              _roomId: _roomId
            }, null, {
              sort: {
                'createAt': -1
              },
              limit: 20
            }, function(err, messages) {
              done(err, messages.reverse())
            })
          }
        ],
        function(err, results) {
          if (err) {
            callback(err)
          } else {
            room = room.toObject()
            room.users = results[0]
            room.messages = results[1]
            callback(null, room)
          }
        });
      }
  })
}

使用async并行地把room中的用户和消息都读取出来。注意,只读取最新的20条消息,且按照时间的排序,最新的在最后。这样能够保证在客户端渲染时,最新的消息在最下面。

OneRoom

让消息只在房间内传递

还有一点,现在聊天室创建的消息,必须是针对特定房间的,这些消息仅在特定的房间内传递。因此:

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

修改MessageCreatorCtrl,多传递给服务端一个参数,_roomId。

而服务端,则需要将消息通过房间的形式,将消息广播开来:

socket.on('createMessage', function(message) {
  Controllers.Message.create(message, function(err, message) {
    if (err) {
      socket.emit('err', {
        msg: err
      })
    } else {
      socket.in(message._roomId).broadcast.emit('messageAdded', message)
      socket.emit('messageAdded', message)
    }
  })
})

到此为止,用户可以进到一个房间,发消息,这些消息只在该房间中传递,我们使用socket.io的room功能实现了这种限制。

用户离开房间

离开房间是一个相对比较复杂的问题,存在很多种情况导致一个用户离开房间:

我们把这些问题分成两类:

于是,我们分两种情况来处理这种问题:

首先socket断开了,修改app.js:

socket.on('disconnect', function() {
  Controllers.User.offline(_userId, function(err, user) {
    if (err) {
      socket.emit('err', {
        mesg: err
      })
    } else {
      if (user._roomId) {
        socket.in(user._roomId).broadcast.emit('leaveRoom', user)
        socket.in(user._roomId).broadcast.emit('messageAdded', {
          content: user.name + '离开了聊天室',
          creator: SYSTEM,
          createAt: new Date(),
          _id: ObjectId()
        })
        Controllers.User.leaveRoom({user: user}, function() {})
      }

    }
  })
})

用户断开后,如果他正在某个房间中,通知在该房间的客户端,该用户已经离开了。

另外一种,就是用户到了其他的页面:

angular.module('techNodeApp').controller('RoomCtrl', function($scope, $routeParams, $scope, socket) {
  // ...
  $scope.$on('$routeChangeStart', function() {
    socket.emit('leaveRoom', {
      user: $scope.me,
      room: $scope.room
    })
  })
  socket.on('leaveRoom', function(leave) {
    _userId = leave.user._id
    $scope.room.users = $scope.room.users.filter(function(user) {
      return user._id != _userId
    })
  })
  // ...
})

即会导致route的变化,我们添加一个监听器,如果地址开始变化,就通知服务端,该用户已经退出该房间了。除此之外,房间还需要监听其他用户退出的情况,做起来很简单,如果有其他用户退出了,就把他从房间的users列表中过滤掉。

服务端处理用户离开的请求也比较简单:

socket.on('leaveRoom', function(leave) {
  Controllers.User.leaveRoom(leave, function(err) {
    if (err) {
      socket.emit('err', {
        msg: err
      })
    } else {
      socket.in(leave.room._id).broadcast.emit('messageAdded', {
        content: leave.user.name + '离开了聊天室',
        creator: SYSTEM,
        createAt: new Date(),
        _id: ObjectId()
      })
      socket.leave(leave.room._id)
      io.sockets.emit('leaveRoom', leave)
    }
  })
})

唯一需要注意的就是将socket从它绑定的房间上解开socket.leave(leave.room._id),这样的话,对应的客户端就不会接收到该房间的消息了。至于leaveRoom的实现,就不细说了。

到此为止,第三章接近尾声,用户已经可以自由进出一个房间,与大家一起交流了。

坏代码的味道

一个聊天室基本完成了,但是我们的app.js中充斥了大量的代码,有坏代码的味道。下一章我们将重新梳理一下整个TechNode架构,看看能不能在上面做点什么,让它更容易维护扩展,添加新的功能。 还有,我们还将使用介绍如何使用一些前端工具,做上线的准备,将这个应用发布出去。