返回介绍

让用户不在匿名

发布于 2024-02-12 19:20:44 字数 22121 浏览 0 评论 0 收藏 0

添加登录页面

简单起见,我们要求用户使用 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 技术交流群。

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

发布评论

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