提供不同的房间
在本章中,我们将为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>
这个页面分成三部分:
- 主体列出了所有的房间,包括房间人数和人的列表;
- 顶部游一个搜索框,可以搜索房间;
- 当用户搜索的房间不存在时,让用户点击创建新的房间。
添加房间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
我们需要两个接口:
- 查找所有的房间,包括当前在这个房间的用户列表,我们使用房间ID到用户列表中查询属于当前房间的用户;
- 创建新的房间。
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>
来看一下在这个模板视图中使用的绑定:
-
ng-change="searchRoom()"
,当搜索框的内容有变化时,就调用RoomsCtrl的searchRoom来过滤房间; -
ng-show="rooms.length == 0 && searchKey"
,当scope中的房间列表长度为0,且存在搜索关键字存在的情况下,就把创建的提示显示出来,否则隐藏。
接下来实现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最鲜明的特点之一。
接下来我们实现用户进入房间的逻辑。
进入单独的房间
在房间列表页面,当用户点击房间时,就跳转到对应的房间页面,但是在这之前,我们需要先与服务器通信,将用户的这个加入房间的动作发送到其他客户端。
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
的房间中,然后触发了三个事件:
-
'joinRoom.' + join.user._id
:通知客户端,这次加入房间成功了,可以跳转至房间了; -
socket.in(join.room._id).broadcast.emit('messageAdded'
,因为之前加入了一个socket的房间,即对这个房间的其他socket广播,发了一条消息,有一个新的用户进入了聊天室,在其他客户端的聊天室页面,即可以看到这条系统通知; -
socket.in(join.room._id).broadcast.emit('joinRoom', join)
:这条消息则是通知客户端的有新的用户加入了房间,客户端的房间列表页和房间页则监听这个事件更新对应的用户列表。
接下来就是进入到具体的房间了:
别忘了本章开始对客户端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条消息,且按照时间的排序,最新的在最后。这样能够保证在客户端渲染时,最新的消息在最下面。
让消息只在房间内传递
还有一点,现在聊天室创建的消息,必须是针对特定房间的,这些消息仅在特定的房间内传递。因此:
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断开了;
- 用户没断网,但去了其他的页面,不管是去到房间列表还是登陆页,导致了页面地址的变化;
于是,我们分两种情况来处理这种问题:
首先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架构,看看能不能在上面做点什么,让它更容易维护扩展,添加新的功能。 还有,我们还将使用介绍如何使用一些前端工具,做上线的准备,将这个应用发布出去。