让用户不在匿名
添加登录页面
简单起见,我们要求用户使用 gmail 登录,通过 gmail 可以拿到用户的 avatar 头像。在此之前我们先对前端代码进行了一下重构,从 technode.js 中,将组件拆了出来,便于代码维护。
拆了之后前端的结构如下:
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
接下来,我们使用 angular 的 router 组件,将 app 分成两个页面:
/
聊天室/login
登录页,让用户输入 gmail 登录
首先,安装 angular-router:
bower install angular-route --save
在 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>
在 technode.js 中添加对 angular-route 的依赖,引入 router 组件:
angular.module('techNodeApp', ['ngRoute'])
新建 router.js,声明对 router 的处理:
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'
})
})
我们采用 html5 的 pushState 来实现路由机制;接下来我们需要把 room 项目的 html 从 index.html 拆出来放到 room.html 中,供 angular 调用;
<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>
在 index.html 中,放一个占位,angular 会根据 url 的不同载入不同的 page,使用不同的 controller;
<div class="container" style="margin-top:100px;">
<div class="row" ng-view></div>
</div>
接下来,加入 login.html,开始设计 TechNode 的登陆页吧!
<form class="form-inline">
<div class="form-group">
<label class="sr-only">Gmail</label>
<input type="email" required class="form-control" placeholder="Gmail Account" />
</div>
<button type="submit" class="btn btn-primary btn-enter">Enter</button>
</form>
这个页面非常简单,就是一个表单,让用户提交 gmail 账号;
用户登录与认证
因为我们是一个单页面的应用程序,因此我们使用 Ajax 来实现用户登录和认证;首先添加一个全局的 controller MainCtrl,用于用户是否登录的检测,如果未登录则跳转至登陆页。
index.html
<body ng-controller="MainCtrl">
...
</body>
/controllers/main.js
angular.module('techNodeApp').controller('MainCtrl', function($scope, $http, $location) {
$http({
url: '/ajax/validate',
method: 'GET'
}).success(function (user) {
}).error(function (data) {
$location.path('/login')
})
})
通过 ajax 获取用户的信息,如果没有获取到,就意味着用户还没有登录,打开登录页面;来看看服务端怎么实现。
为了便于实现,我们在服务端采用 mongodb 来存储登录用户的信息;我们使用 mongoose 来操作 mongodb,首先定义 user 的 schema:
在根目录下新建 models 文件夹,新建 user.js 和 index.js 文件:
user.js:
var mongoose = require('mongoose')
var Schema = mongoose.Schema
var User = new Schema({
email: String,
name: String,
avatar: String
});
module.exports = User
用户输入 email,我们通过解析用户的 email 获取 name 和 avatar 头像地址;再来看看 index.js 文件:
var mongoose = require('mongoose')
mongoose.connect('mongodb://localhost/technodechapter02')
exports.User = mongoose.model('User', require('./user'))
我们使用 mongoose 来访问 mongodb,使用 user 的 scheme 生成模型 User,记得使用 npm install mongoose --save
安装 mongoose;
接下来我们就要编写验证的逻辑了,新建 controllers 文件夹,新建 user.js 文件,为了便于管理代码,我们把 user 的业务逻辑都放在 controller/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 查找用户,一个是通过 email 查找,如果没找到就根据 email 创建一个新账户,头像地址使用 gravatar 来生成;
来看看 Ajax 接口的实现:
// ...
var Controllers = require('./controllers')
app.use(express.bodyParser())
app.use(express.cookieParser())
app.use(express.session({
secret: 'technode',
cookie:{
maxAge: 60 * 1000
}
}))
// ...
app.get('/ajax/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('/ajax/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('/ajax/logout', function (req, res) {
req.session._userId = null
res.json(401)
})
// ...
我们使用 express 提供的 session 模块来管理用户的认证,整个认证过程是这样的:
- 客户端调用 validate 测试用户是否登录,服务端查看在会话(session)中是否已经保存了用户的_userId,如果是则表示用户已经登录了,从数据库将用户信息读出来;发给客户端;
- 如果会话中没有用户 Id,即用户未登录,客户端则通过 login 接口登录,服务端根根据用户填写的 email 地址到 mongodb 中查找用户,如果查找不到就创建一个新用户,然后把用户 Id 保存在 session 中,返回用户信息给客户端,登录成功;
- 还提供了一个 logout 的 ajax 接口,即简单清除了 session 中用户 id,则用户在页面刷新验证时必须再次登录才行;
到此,整个服务端的逻辑完成了,来看看客户端
回到登录页面 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="Gmail Account" />
</div>
<button type="submit" class="btn btn-primary btn-enter">Enter</button>
</form>
添加了一个 submit 的处理,在 input 上绑定了一个 email,变量,看到 LoginCtrl 之后,它们的功能你就一幕了然了:
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('/')
}).error(function (data) {
$location.path('/login')
})
}
})
即当用户输入了 email 之后,提交表单时,就调用 controller 的 login 方法,该方法将 email 发送给服务端,发起 login 的 ajax 请求;登录成功就跳转到聊天室 /
;
$scope.$emit('login', user)
是干什么用户的呢,我们先来看看 MainCtrl 了实现就一幕了然了;
首先在页面导航的右上角,显示用户的头像和登出链接:
<nav class="collapse navbar-collapse" role="navigation">
<ul class="nav navbar-nav navbar-right" ng-show="me">
<li>
<img src="{{me.avatarUrl}}" title="{{me.name}}" class="img-rounded"/>
</li>
<li>
<a href="" ng-click="logout()">Log out</a>
</li>
</ul>
</nav>
如果变量 me 为真,即如果存在用户信息就显示用户头像和登出了解,否则什么也不显示;
最后来看看 MainCtrl:
angular.module('techNodeApp').controller('MainCtrl', function($scope, $http, $location) {
$http({
url: '/ajax/validate',
method: 'GET'
}).success(function (user) {
$scope.me = user
$location.path('/')
}).error(function (data) {
$location.path('/login')
})
$scope.logout = function() {
$http({
url: '/ajax/logout',
method: 'GET'
}).success(function () {
$scope.me = null
$location.path('/login')
})
}
$scope.$on('login', function (evt, me) {
$scope.me = me
})
})
MainCtrl 做了三个事情:
- 因为 MainCtrl 是一个全局的 Controller,即在每次页面刷新时,都会调用这个模块,因此我们用来做权限验证的关口。在每次页面刷新时都通过 ajax 调用 validate 接口,查看用户是否已经登录,如果登录了,就将用户信息赋值给 me,由于 angular 的双向绑定,用户头像就会显示出来;
- 提供了一个 logout 函数,当用户点击登出链接时,就会通过 ajax 调用服 logout 接口,将用户会话中的用户 id 清除,同时清除客户端的用户信息
$scope.me = null
,将页面跳转至登录页; - MainCtrl 还通过$scope.$on 监听着来自子域 LoginCtrl 的 login 事件,即当用户通过登陆页登录成功后,将用户信息发送给 MainCtrl,更新用户信息;至于为什么需要这样做,这和 angular 的 scope 原理相关,大家可以查看相关资料了解。
socket.io 认证
除了每次页面刷新时通过 ajax 认证之外,我们还需要对 socket 访问进行认证;socket.io 提供了认证的接口,只需要我们简单配置一下即可:
// ...
var parseSignedCookie = require('connect').utils.parseSignedCookie
var MemoryStore = require('connect').session.MemoryStore
var Cookie = require('cookie')
var sessionStore = new MemoryStore()
app.use(express.bodyParser())
app.use(express.cookieParser())
app.use(express.session({
secret: 'technode',
cookie: {
maxAge: 60 * 1000
},
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')
}
})
我们通过 io.set('authorization',callback) 这个接口进行认证,手动解析了客户端的 session 数据,如果找到 session 且 session 中存在用户信息的话,认证成功,否则认证失败; 在这里引入了 connnect 和 cookie 两个类库,别忘了使用 npm 安装; 还有,我们把 session 的存储对象暴露了出来,我们才得以在 socket 认证的过程中手动解析出 session。
到此我们用户认证完成了,用户可以通过 email 登录,也可以退出,在用户登录后,我们可以拿到用户的信息,这样在用户发送消息时,我们就可以显示用户名啦!
显示用户名和在线用户列表
显示用户名
修改 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 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>
在用户发送消息时,将用户信息记录下来:
angular.module('techNodeApp').controller('MessageCreatorCtrl', function($scope, socket) {
$scope.createMessage = function () {
socket.emit('messages.create', {
message: $scope.newMessage,
creator: $scope.me
})
$scope.newMessage = ''
}
})
在这里我们发送的消息不在是一个字符串,而是一个携带了用户信息的 json 对象;
重启服务器,登录发条消息,终于,TechNode 的用户不再是匿名的了!
显示在线用户
如果能够看到哪些用户同时在线就好了,让我们开始实现这个功能吧!
我们将用户是否在线的状态存储在数据库中,因此需要扩展 User scheme 的字段:
var User = new Schema({
email: String,
name: String,
avatarUrl: String,
online: Boolean
});
在用户登录或者登出是修改用户的在线状态:
app.post('/ajax/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('/ajax/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 这两个方法就比较容易:
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)
}
既然用户是否在线的状态已经存在数据库中了,那接下来将其读出来显示在聊天室的右侧就行啦!
首先修改 room controller,不但从服务端读取 messages,同时还要读取在线的用户列表:
angular.module('techNodeApp').controller('RoomCtrl', function($scope, socket) {
socket.on('technode.read', function (technode) {
$scope.technode = technode
})
socket.on('messages.add', function (message) {
$scope.technode.messages.push(message)
})
socket.emit('technode.read')
})
我们把 messages 和 users 都放到 technode 这个对象中,修改 socket 服务端,将在线用户列表和 messages 一起读出来发送给客户端:
io.sockets.on('connection', function(socket) {
socket.on('technode.read', function() {
Controllers.User.getOnlineUsers(function (err, users) {
if (err) {
socket.emit('err', {msg: err})
} else {
socket.emit('technode.read', {users: users, messages: messages})
}
})
})
socket.on('messages.create', function(message) {
messages.push(message)
io.sockets.emit('messages.add', message)
})
})
getOnlineUsers 非常简单:
exports.getOnlineUsers = function(callback) {
db.User.find({
online: true
}, callback)
}
接下来就是把在线用户列表在客户端 render 出来了,其实只需要修改 room 的模板即可:
<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 technode.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 technode.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('users.add', user)
}
})
socket.on('disconnect', function() {
Controllers.User.offline(_userId, function(err, user) {
if (err) {
socket.emit('err', {
mesg: err
})
} else {
socket.broadcast.emit('users.remove', user)
}
})
});
// ...
})
我们添加了 socket 连上和断开的处理,通知客户端有用户连上或者下线了。客户端只需监听这两个事件即可:
socket.on('users.add', function (user) {
$scope.technode.users.push(user)
})
socket.on('users.remove', function (user) {
_userId = user._id
$scope.technode.users = $scope.technode.users.filter(function (user) {
return user._id != _userId
})
})
至此,本章最核心的部分已经完成,现在你可以在聊天室的右侧查看在线的用户的了。
消息持久化、系统消息
到目前为止,聊天室的消息并没有存放在数据库中,而且消息没有创建时间等等一些参数,现在我们就来处理这些问题;首先创建一个消息的 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
},
createAt:{type: Date, default: Date.now}
})
module.exports = Message
在消息中,我们存放了消息的内容和创建时间,甚至还将消息创建人的详细信息也包含在了消息里,这样做的好处是,浪费一点空间,缩短查询时的时间;其次,消息中的 creator 数据并不需要保持绝对的正确。
根据 Scheme 生成 Message 模型类:
// ...
exports.Message = mongoose.model('Message', require('./message'))
添加一个消息的 controller,实现消息的写入和查询:
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('technode.read', 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('technode.read', {
users: results[0],
messages: results[1]
})
}
});
})
socket.on('messages.create', function(message) {
Controllers.Message.create(function (err, message) {
if (err) {
socket.emit('err', {msg: err})
} else {
io.sockets.emit('messages.add', 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 的 directive,这个 directive 可以动态地更新 time 中的时间显示,开始可能是 几秒前
,随着实现的推移,能够智能地便成 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>
我们添加了一个语言包,需要在 technode.js 对 angular-moment 和语言包进行配置:
angular.module('techNodeApp', ['ngRoute', 'angularMoment']).
run(function ($window) {
$window.moment.lang('zh-cn')
})
至此,我们将 message 持久化了,而且保存了消息的创建时间,并在前端将时间显示出来。
提供一些系统消息
我们为用户提供了一些系统的消息,比如其他用户登录退出的信息等等;这部分消息都是临时的,因此我们不会把它们加入到数据库中,动态生成即可。
比如我们添加两个用户登录和登出的消息:
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('users.add', user)
socket.broadcast.emit('messages.add', {
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('users.remove', user)
socket.broadcast.emit('messages.add', {
content: user.name + '离开了聊天室',
creator: SYSTEM,
createAt: new Date()
})
}
})
});
其实我们就是伪造了两条临时消息。
我不喜欢你们!
我们的用户使用各种不同的技术,我们都不喜欢异类(使用与自己不同技术的人),他们需要自己的空间。下一张,我们要给 TechNode 加上房间的功能。这样同一类人就可以聚到一起聊天啦!
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论