提供不同的房间
在本章中,我们将为 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="island205@gmail.com" 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
修改数据模型
因为我们加入了房间,所以我们有必要修改一下数据模型:
首先添加 room 的 scheme:
var mongoose = require('mongoose')
var Schema = mongoose.Schema
var Room = new Schema({
name: String,
createAt:{type: Date, default: Date.now}
});
module.exports = Room
很简单,把房间的名字保存起来;
修改 message 的 scheme:
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 的外键,让消息与房间对应起来。
同理为 user 也添加一个:
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
socket.on('rooms.create', function (room) {
Controllers.Room.create(room, function (err, room) {
if (err) {
socket.emit('err', {msg: err})
} else {
io.sockets.emit('rooms.add', room)
}
})
})
sockets.on('rooms.read', function () {
Controllers.Room.read(function (err, rooms) {
if (err) {
socket.emit('err', {msg: err})
} else {
socket.emit('rooms.read', rooms)
}
})
})
登录后跳转至房间列表
首先,当用户登录成功后,并不直接跳到聊天室,而是聊天室列表。我们先修改一下 router:
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'
})
})
添加 RoomsCtrl:
angular.module('techNodeApp').controller('RoomsCtrl', function($scope) {
// Nothing
})
目前什么都不干,过一下在来实现里面的逻辑。
修改 main 控制器和 login 控制器,当用户登录成功后,跳至房间列表:
// 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')
})
}
})
房间列表
我们的目标就是把房间从服务器端读取过来,显示在 rooms 页面上,首先在 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 的 sarchRoom 来过滤房间;- ng-show="rooms.length == 0 && searchKey",当 scope 中的房间列表长度为 0,且存在搜索关键字存在的情况下,就把创建的提示显示出来,否则隐藏。
接下来实现 RoomsCtrl:
angular.module('techNodeApp').controller('RoomsCtrl', function($scope, socket) {
$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('rooms.create', {
name: $scope.searchKey
})
}
socket.on('rooms.read', function (rooms) {
$scope.rooms = $scope._rooms = rooms
})
socket.on('rooms.add', function (room) {
$scope._rooms.push(room)
$scope.searchRoom()
})
socket.emit('rooms.read')
})
首先我们通过 socket.emit('rooms.read')
向服务端发起读取房间列表的请求,当服务端将房间返回后,我们将原始数据存储在_rooms 中,rooms 则拷贝一份;
$scope.searchRoom
则是过滤 rooms 的实现,我们仅仅做了简单的字符串包含的匹配;这也正式我们把原始数据保存在_rooms 中的原因。
$scope.createRoom = function () {
socket.emit('rooms.create', {
name: $scope.searchKey
})
}
createRoom 通过调用服务端的接口创建房间,房间创建完成,会返回一个 rooms.add 的事件,我们将新的房间加入到_rooms 中,手动进行一次搜索。将新增的 room 同步到 rooms 中。
由于 Angular.js 动态绑定的特性,随着 rooms 的变化,dom 层面能够自动更新,我们无需手动操作 DOM,维护 DOM 的状态,这就是最鲜明的特点之一。
接下来我们将来实现用户进入房间的逻辑了。
进入单独的房间
在房间列表页面,当用户点击房间时,就跳转到对应的房间页面,但是在这之前,我们需要先与服务器通信,将用户的这个加入动作发送到其他客户端。
angular.module('techNodeApp').controller('RoomsCtrl', function($scope, $location, socket) {
// ...
$scope.enterRoom = function (room) {
socket.emit('users.join', {
user: $scope.me,
room: room
})
}
socket.on('users.join.' + $scope.me._id, function (join) {
$location.path('/rooms/' + join.room._id)
})
socket.on('users.join', 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)">
。当服务端处理完用户进入房间的动作之后,会向客户端发送一个 users.join.52b380a837a4f24736000001
的事件,即对应之前客户端发送的那个加入房间的请求。客户端收到这个事件之后,就跳转到具体的聊天室去了。
这个 controller 中,还监听了一个事件, users.join
,即当有用户加入到某个房间后,服务端都会触发这个事件,便于客户端更新在房间里的用户。
来看看服务端是如何实现的:
user 的 controller 提供一个接口:
exports.joinRoom = function (join, callback) {
db.User.findOneAndUpdate({
_id: join.user._id
}, {
$set: {
online: true,
_roomId: join.room._id
}
}, callback)
}
用户加入某个房间后,将加入的房间 id 保存在下来。
socket.on('users.join', function(join) {
Controllers.User.joinRoom(join, function(err) {
if (err) {
socket.emit('err', {
msg: err
})
} else {
socket.join(join.room._id)
socket.emit('users.join.' + join.user._id, join)
socket.in(join.room._id).broadcast.emit('messages.add', {
content: join.user.name + '进入了聊天室',
creator: SYSTEM,
createAt: new Date(),
_id: ObjectId()
})
socket.in(join.room._id).broadcast.emit('users.join', join)
}
})
})
socket 层的处理:当将用户加入动作写入数据库之后,首先调用 socket.join
方法,将当前的 socket 加入到一个键值为 join.room._id
的房间中,然后触发了三个事件:
'users.join.' + join.user._id
:通知客户端,这次加入房间成功了,可以跳转至房间了;socket.in(join.room._id).broadcast.emit('messages.add'
,因为之前加入了一个 socket 的房间,即对这个房间的其他 socket 广播,发了一条消息,有一个新的用户进入了聊天室,在其他客户端的聊天室页面,即可以看到这条系统通知;socket.in(join.room._id).broadcast.emit('users.join', 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.on('rooms.read.' + $routeParams._roomId, function(room) {
$scope.room = room
})
socket.emit('rooms.read', {
_roomId: $routeParams._roomId
})
socket.on('messages.add', function(message) {
$scope.room.messages.push(message)
})
socket.on('users.join', function (join) {
$scope.room.users.push(join.user)
})
})
首先修改 RoomCtrl,通过 $routeParams
获取 route 参数,触发 rooms.read
事件,到服务器端读取房间数据;其次是接受新的消息,并当用户加入到房间时,将用户加入到用户列表中。
<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
换成了房间名, technode
换成了 room
。
接下来实现服务器端:
socket.on('rooms.read', function(data) {
if (data && data._roomId) {
Controllers.Room.getById(data._roomId, function(err, room) {
if (err) {
socket.emit('err', {
msg: err
})
} else {
socket.emit('rooms.read.' + data._roomId, room)
}
})
} else {
Controllers.Room.read(function(err, rooms) {
if (err) {
socket.emit('err', {
msg: err
})
} else {
socket.emit('rooms.read', rooms)
}
})
}
})
修改 rooms.read
的 socket 响应,如果客户端的请求数据中包含 _roomId
的话,就读取单独房间的数据,而不是读取所有的房间。
controller room 则添加需要的 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('messages.create', function(message) {
Controllers.Message.create(message, function(err, message) {
if (err) {
socket.emit('err', {
msg: err
})
} else {
socket.in(message._roomId).broadcast.emit('messages.add', message)
socket.emit('messages.add', message)
}
})
})
到此为止,用户可以进到一个房间,发消息,这些消息只在该房间中传递,我们使用 socket.io 的 room 功能实现了这种限制。
离开房间
离开房间是一个比较棘手的问题,存在很多种情况导致一个用户离开房间:
- 用户退到了房间列表页;
- 用户刷新了页面;
- 用户退出;
- 用户直接关闭了网页;
- 用户断网了……。
我们把这些问题分成两类:
- 用户断网了,或者是刷新网页, 关闭网页,即 socket 断开了;
- 用户没断网,但去了其他的页面,不管是去到房间列表还是登陆页,导致了页面地址的变化;
于是,我们分两种情况来处理这种问题:
首先 socket 断开了,
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('users.leave', user)
socket.in(user._roomId).broadcast.emit('messages.add', {
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('users.leave', {
user: $scope.me,
room: $scope.room
})
})
socket.on('users.leave', function(leave) {
_userId = leave.user._id
$scope.room.users = $scope.room.users.filter(function(user) {
return user._id != _userId
})
})
// ...
})
即会导致 route 的变化,我们添加一个监听器,如果地址开始变化,就通知服务端,该用户已经退出该房间了。除此之外,房间还需要监听其他用户退出的情况,做起来很简单,如果有其他用户退出了,就把他从房间的 users 列表中过滤掉。
服务端处理用户离开的请求也比较简单:
socket.on('users.leave', function(leave) {
Controllers.User.leaveRoom(leave, function(err) {
if (err) {
socket.emit('err', {
msg: err
})
} else {
socket.in(leave.room._id).broadcast.emit('messages.add', {
content: leave.user.name + '离开了聊天室',
creator: SYSTEM,
createAt: new Date(),
_id: ObjectId()
})
socket.leave(leave.room._id)
io.sockets.emit('users.leave', leave)
}
})
})
唯一需要注意的就是将 socket 从它绑定的房间上解开 socket.leave(leave.room._id)
,这样的话,对应的客户端就不会接收到房间的消息了。至于 leaveRoom 的实现,就不细说了。
到此为止,第三章接近尾声,用户已经可以自由进出一个房间,与大家一起交流了。
使用 MongoStore 来存储 session
在之前的开发中,每次重启服务器,我们都需要重新登录,因为我们的 session 数据是存储在内存中的,服务器重启就被清除了,到时客户端需要频繁的登录,这并不是我们客户想要的。
我们使用 MongoStore 将 session 存储在 mongodb 中,这样的话,重启服务器,用户还能继续会话,无需重新登录。
var MongoStore = require('connect-mongo')(express)
var sessionStore = new MongoStore({
url: 'mongodb://localhost/technodechapter03'
})
别忘了使用 npm install connect-mongo --save
安装 connect-mongo
坏代码的味道
一个聊天室基本完成了,但是我们的 app.js 中充斥了大量的代码,有坏代码的味道。下一章我们将重新梳理一下整个 TechNode 架构,看看能不能在上面做点什么,让它更容易维护扩展,添加新的功能。 还有,我们还将使用介绍如何使用一些前端工具,做一些上线的准备,将这个应用发布出去。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论