返回介绍

提供不同的房间

发布于 2024-02-12 19:21:37 字数 20343 浏览 0 评论 0 收藏 0

在本章中,我们将为 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文