架构优化与发布
在本章中,我们将对前三章中一步步编写出来聊天室重新做一次梳理,进行一些优化,让整个项目更容易理解和扩展。其次我们还会介绍一些前端流行的工具,帮助我们构建项目,便于发布。
目前的项目结构
目前整个项目的架构如下图:
箭头代表了读取数据的流向,服务端和客户端基本上都分为三层:
- 服务端:在 mongodb 和 mongoose 之上,我们添加了一层模型的 controller,这一层直接处理一些业务相关的逻辑;在这之上,我们直接通过 http API 或者 socket.io 将所提供的接口暴露出来;这一块的代码我们全部写在了 app.js 中;
- 客户端:针对不同的组件或者页面,我们对应了不同的 controller,而这些 controller 都是通过$http 或者 socket service 直接于服务端通信的;各个 controller 之间共享数据很困难。
总结一下:
- 将服务端的 service 逻辑从 app.js 分拆到 http 和 socket service 中
- 在客户端提供一个统一的数据接口层,向上为 controller 提供数据服务,向下和服务端通信,同步数据。
新的结构应该像下面这样:
分拆 http 和 socket 服务
首先简化 app.js:
// ...
var api = require('./services/api')
var socketApi = require('./services/socketApi')
// ...
app.post('/api/login', api.login)
app.get('/api/logout', api.logout)
app.get('/api/validate', api.validate)
// ...
io.sockets.on('connection', function(socket) {
socketApi.connect(socket)
socket.on('disconnect', function() {
socketApi.disconnect(socket)
})
socket.on('technode', function(request) {
socketApi[request.action](request.data, socket, io)
})
})
// ...
我们把 http 和 socket 的回调分别放到 api.js 和 socketApi.js 中,在 socket 通信方面做了简化,使用 technode
作为统一的事件名,而需要调用的接口名,则由请求数据中的 action
来决定。每个 socket 请求都会变成下面这样:
客户端的请求:
socket.emit('technode', {
action: 'getRoom'
})
下面是服务端的返回:
socket.emit('technode', {
"action": "getRoom",
"data": [{
"name": "Socket.IO",
"_id": "52b0e5dd0a5e66fa26000001",
"__v": 0,
"createAt": "2013-12-18T00:01:33.528Z",
"users": [],
"messages": []
}]
})
客户端则根据 action,进行不同的处理:
socket.on('technode', function (data) {
switch (data.action) {
// ...
}
})
而本身 api.js 和 socketApi.js 内的处理,与第三章的基本无异,不再细说。
客户端缓存
为什么需要客户端缓存?有两点原因:
- 在第三章的实现中,在房间列表和房间切换时,controller 都会通过 socket 从服务端重新获取房间列表或房间;
- 在第三章的实现中,我们无法在 controller 之间共享数据,比如在 LoginCtrl 中,用户登录后,我们需要更$rootScope 的用户信息,我们采用了 scope 事件机制来实现。
我们需要一个缓存数据和共享数据的组件,这个组件将服务端请求来的数据缓存下来,避免重复的从服务端请求相同的数据,其次是对所有的 controller 提供接口,让 controller 可以共享(读取、修改)同一块数据。
我们把这个组件命名为 server,与服务端通信完全通过这个组件,数据缓存到这个组件之中,controller 直接与它通信,不必关系正真的服务器是什么样的。
angular.module('techNodeApp').factory('server', ['$cacheFactory', '$q', '$http', 'socket', function($cacheFactory, $q, $http, socket) {
var cache = window.cache = $cacheFactory('technode')
socket.on('technode', function(data) {
switch (data.action) {
case 'getRoom':
if (data._roomId) {
angular.extend(cache.get(data._roomId), data.data)
} else {
data.data.forEach(function (room) {
cache.get('rooms').push(room)
})
}
break
// case something else
// handle for socket events
}
})
socket.on('err', function (data) {
// handle server err
})
return {
validate: function() {
var deferred = $q.defer()
$http({
url: '/api/validate',
method: 'GET'
}).success(function(user) {
angular.extend(cache.get('user'), user)
deferred.resolve()
}).error(function(data) {
deferred.reject()
})
return deferred.promise
}
// more API
}
}])
在 server 中,我们使用两个 Angular.js 提供的组件, $q
和 $cacheFactory
。
$q
$q 是 Angular.js 对 JavaScript 异步变成模式 Promise 的实现,参考了 https://github.com/kriskowal/q 。在 TechNode 对它的用法相对比较简单,仅仅是将 ajax 请求隐藏起来。以 server.validate 为例:
validate: function() {
var deferred = $q.defer()
$http({
url: '/api/validate',
method: 'GET'
}).success(function(user) {
angular.extend(cache.get('user'), user)
deferred.resolve()
}).error(function(data) {
deferred.reject()
})
return deferred.promise
}
$q.defer()
获取一个 differed(推迟)对象,然后 return deferred.promise
先返回 promise(承诺),在服务器端成功返回后,resolve(兑现)承诺,或者遇到问题,reject(拒绝)兑现。
在 technode.js 中我们可以这样使用:
server.validate().then(function() {
if ($location.path() === '/login') {
$location.path('/rooms')
}
}, function() {
$location.path('/login')
})
server.validate()
获取 promise(承诺)对象,then(resolvedCallback, rejectCallack)(然后)根据承诺的兑现情况进行不同的处理。
换句话说,technode.js 中的 techNodeApp
问 server,用户是不是登录了,server 必须调用服务端接口进行验证,因此 server 给 techNodeApp
许诺, techNodeApp
则只需要针对许诺是否兑现进行处理就好了。
所有与 http 请求相关的接口,我们都做了相似的处理。
$cacheFactory
$cacheFactory 是 Angular 提供的缓存组件,该组件直接将数据存放在内存中。
var cache = window.cache = $cacheFactory('technode')
// ...
cache.put('rooms', [])
// ...
cache.get('rooms') && cache.get('rooms').forEach(function(room) {
if (room._id === _roomId) {
room.users = room.users.filter(function(user) {
return user._id !== _userId
})
}
})
直接调用$cacheFactory,传入 cacheId,Angular 就为我构造出一块缓存区域,我们就可以通过 get、put 等等方法来存储或者获取缓存数据了。
$cacheFactory 提供了一种 TechNode 中未使用的特性,即这块缓存可以是 LRU 的,即这块缓存是有大小的(避免缓存太大了,影响了性能),并且这块缓存使用 LRU 算法来淘汰长时间未使用的数据。
controller 与 server
有了 server,我们来看看 controller 有合变化?这是原来的 RoomCtrl 的代码:
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)
})
socket.on('users.leave', function(leave) {
_userId = leave.user._id
$scope.room.users = $scope.room.users.filter(function(user) {
return user._id != _userId
})
})
})
这是基于 server 组件修改后的 RoomCtrl:
angular.module('techNodeApp').controller('RoomCtrl', ['$scope', '$routeParams', '$scope', 'server', function($scope, $routeParams, $scope, server) {
$scope.room = server.getRoom($routeParams._roomId)
// ...
}])
- RoomCtrl 不再直接于服务端通信读取当前的房间信息
- 无需监听用户进入或者离开,监听新消息
RoomCtrl 只需调用 server.getRoom,传入房间的 id 即可。那房间信息不是需要到服务端读取么?这是怎么实现的?
这完全得益于 Angular 数据绑定特性,即数据变化,视图也会跟着变化:
getRoom: function(_roomId) {
if (!cache.get(_roomId)) {
cache.put(_roomId, {
users: [],
messages: []
})
socket.emit('technode', {
action: 'getRoom',
data: {
_roomId: _roomId
}
})
}
return cache.get(_roomId)
}
这里的处理方式与 promise
有异曲同工之妙, getRoom
方法,如果在缓存中没有找到房间的数据,就先放入一个房间对象,不过里面的数据都是空的(此时,RoomCtrl 渲染出来的是一个空的房间视图),然后通过 socket 向服务端请求房间数据;如果找到就直接返回从缓存中获取的房间数据,RoomCtrl 就可以渲染出来一个正常的房间视图。
而在服务端返回房间信息后,
case 'getRoom':
if (data._roomId) {
angular.extend(cache.get(data._roomId), data.data)
} else {
data.data.forEach(function (room) {
cache.get('rooms').push(room)
})
}
我们使用服务端的数据扩展空房间即可,Angular 即根据数据的变化,渲染出新的房间视图。
我们必须保证更新的房间对象必须是视图绑定的对象,因此我们一开始就返回一个房间对象,后面只是修改这个对象的属性。
同理,RoomCtrl 也无需出来用户进入或者离开房间,有新消息这类事件,因为 server 组件会自动更新对应的数据,RoomCtrl 只需要按照数据渲染即可。
好了,我们利用客户端缓存和 Angular 数据绑定特性,大大简化了 TechNode 控制器层。到此,我们的开发之旅已经接近尾声,接下来,我们将学习如何将前端程序打包,发布!
使用 Grunt 打包 TechNode
开发时,为了解耦和便于维护,我们把代码拆成单独的文件,JavaScript 代码、CSS 代码和 HTML 都是单独的。在生产环境中,为了提高性能,我们需要把这些分开的文件合并到一起。如果你的网站使用 CDN 的化,我们还需要给每个文件,添加上唯一的标示,便于处理 CDN 的缓存。
Grunt 是目前 JavaScript 最流行的项目自动化构建工具。Grunt 官方提供了很多插件,也有大量的第三方插件。我们可以轻松地使用 Grunt 检查、压缩合并代码,甚至发布应用程序。我们将基于 grunt-usemin 等几个流行的 Grunt 插件来构建 TechNode 项目。
首先我们需要做一些准备,安装 Grunt 命令行和运行时,在 TechNode 根目录新建 Gruntfile.js。
npm install -g grunt-cli && npm install grunt --save-dev && touch Gruntfile.js
为了使用 grunt-usemin 来压缩我们的代码,我们需要在 index.html 添加一些特殊的注释来来帮助 grunt-usemin 找到要合并的文件:
<!-- build:css /css/technode.css -->
<link rel="stylesheet" href="/components/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/styles/style.css">
<link rel="stylesheet" href="/styles/login.css">
<link rel="stylesheet" href="/styles/rooms.css">
<link rel="stylesheet" href="/styles/room.css">
<!-- endbuild -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<!-- build:js /script/technode.js -->
<script type="text/javascript" src="/components/jquery/jquery.js"></script>
<script type="text/javascript" src="/components/bootstrap/dist/js/bootstrap.min.js"></script>
<script type="text/javascript" src="/components/angular/angular.js"></script>
<script type="text/javascript" src="/components/angular-route/angular-route.js"></script>
<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>
<script type="text/javascript" src="/technode.js"></script>
<script type="text/javascript" src="/services/socket.js"></script>
<script type="text/javascript" src="/services/server.js"></script>
<script type="text/javascript" src="/router.js"></script>
<script type="text/javascript" src="/directives/auto-scroll-to-bottom.js"></script>
<script type="text/javascript" src="/directives/ctrl-enter-break-line.js"></script>
<script type="text/javascript" src="/controllers/login.js"></script>
<script type="text/javascript" src="/controllers/rooms.js"></script>
<script type="text/javascript" src="/controllers/room.js"></script>
<script type="text/javascript" src="/controllers/message-creator.js"></script>
<!-- endbuild -->
我们分别在 css 和 javascript 的引用周围加上了注释, <!-- build:css /css/technode.css -->
标示我们需要把下面这些 css 都合并到 technode.css 这个文件中。同理 javascript 全都合并到 technode.js 中。
注意,socket.io.js 这个文件并没有包含进来,因为它是 socket.io 自己输入的,并没有在我们的自己的源码中。当然,我们甚至可以把这个文件保存到源码中,自己引用也是可以的。
首先使用 grunt-contrib-copy 将不需要打包压缩的文件拷贝到 build 目录中,修改 Gruntfile.js
module.exports = function (grunt) {
grunt.initConfig({
copy: {
main: {
files: [
{expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'},
{'build/index.html': 'static/index.html'},
{'build/favicon.ico': 'static/favicon.ico'}
]
}
}
})
grunt.loadNpmTasks('grunt-contrib-copy')
grunt.registerTask('default', [
'copy'
])
}
grunt-usemin 为我们提供了一个 useminPrepare 的 task,这个 task 就是基于我们在 index.html 文件中的配置自动生成合并和压缩代码的配置:
module.exports = function (grunt) {
grunt.initConfig({
copy: {
main: {
files: [
{expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'},
{'build/index.html': 'static/index.html'},
{'build/favicon.ico': 'static/favicon.ico'}
]
}
},
useminPrepare: {
html: 'static/index.html',
options: {
dest: 'build'
}
}
})
grunt.loadNpmTasks('grunt-usemin')
grunt.loadNpmTasks('grunt-contrib-copy')
grunt.registerTask('default', [
'copy',
'useminPrepare'
])
}
npm install grunt-usemin --save-dev
,运行 grunt
试试看:
Running "useminPrepare:html" (useminPrepare) task
Going through static/index.html to update the config
Looking for build script HTML comment blocks
Configuration is now:
concat:
{ generated:
{ files:
[ { dest: '.tmp/concat/css/technode.css',
src:
[ 'static/components/bootstrap/dist/css/bootstrap.min.css',
'static/styles/style.css',
'static/styles/login.css',
'static/styles/rooms.css',
'static/styles/room.css' ] },
{ dest: '.tmp/concat/script/technode.js',
src:
[ 'static/components/jquery/jquery.js',
'static/components/bootstrap/dist/js/bootstrap.min.js',
'static/components/angular/angular.js',
'static/components/angular-route/angular-route.js',
'static/components/moment/moment.js',
'static/components/angular-moment/angular-moment.js',
'static/components/moment/lang/zh-cn.js',
'static/technode.js',
'static/services/socket.js',
'static/services/server.js',
'static/router.js',
'static/directives/auto-scroll-to-bottom.js',
'static/directives/ctrl-enter-break-line.js',
'static/controllers/login.js',
'static/controllers/rooms.js',
'static/controllers/room.js',
'static/controllers/message-creator.js' ] } ] } }
uglify:
{ generated:
{ files:
[ { dest: 'build/script/technode.js',
src: [ '.tmp/concat/script/technode.js' ] } ] } }
cssmin:
{ generated:
{ files:
[ { dest: 'build/css/technode.css',
src: [ '.tmp/concat/css/technode.css' ] } ] } }
它为我们生成好了,本来需要手动编写的其他 grunt task 的配置,接下来,安装其他几个需要的 grunt task,继续修改 Gruntfile.js:
module.exports = function (grunt) {
grunt.initConfig({
copy: {
main: {
files: [
{expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'},
{'build/index.html': 'static/index.html'},
{'build/favicon.ico': 'static/favicon.ico'}
]
}
},
useminPrepare: {
html: 'static/index.html',
options: {
dest: 'build'
}
}
})
grunt.loadNpmTasks('grunt-usemin')
grunt.loadNpmTasks('grunt-contrib-copy')
grunt.loadNpmTasks('grunt-contrib-concat')
grunt.loadNpmTasks('grunt-contrib-uglify')
grunt.loadNpmTasks('grunt-contrib-cssmin')
grunt.registerTask('default', [
'copy',
'useminPrepare',
'concat',
'uglify',
'cssmin'
])
}
安装好新的依赖,再运行 grunt 试试看。首先 concat 根据 useminPrepare 生成的配置,将 css 和 js 分别合并成了.tmp/concat/css/technode.css 和.tmp/concat/script/technode.js;然后 uglify 和 cssmin 分别将这两个文件压缩成了 build/css/technode.css 和 build/script/technode.js,我们的 css 文件和 js 文件就打包压缩好了。
除此之外我们还需要把 pages 中的 html 内联到 index.html 中。在 Angular.js 中,我们既可以将模板文件单独放在不同的 html 文件中,也可以像下面这样,内联在 html 中:
<script type="text/ng-template" id="/pages/login.html">
<form class="form-inline form-login" 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 账号" />
</div>
<button type="submit" class="btn btn-primary btn-enter">进入</button>
</form>
</script>
grunt-inline-angular-templates
就可以实现这样的需求:
inline_angular_templates: {
dist: {
options: {
base: 'static/',
prefix: '/'
},
files: {
'build/index.html': ['static/pages/*.html']
}
}
}
使用 grunt-rev,为静态文件加上唯一标示,使用 grunt-contrib-clean 在每次打包开始时,清除.tmp 和 build 里的内容:
rev: {
options: {
encoding: 'utf8',
algorithm: 'md5',
length: 8
},
assets: {
files: [{
src: [
'build/**/*.{jpg,jpeg,gif,png,js,css,eot,svg,ttf,woff}'
]
}]
}
},
clean: {
main:['.tmp', 'build']
}
最后,使用 grunt-usemin 提供的 task usemin,将 html 中标记的合并区块已经 css 中的字体引用使用 build 目录中对应的压缩做了唯一标记的文件名替换掉:
grunt.registerTask('default', [
'clean',
'copy',
'useminPrepare',
'concat',
'uglify',
'cssmin',
'rev',
'usemin',
'inline_angular_templates'
])
于是我们整个构建的过程结束了,所有文件都按照我们想要的方式处理好了。
我们再来回顾一下打包的过程,开始那么多的 js,首先被 concat 到了 tmp/concat/technode.js 中,然后 aglify 压缩到 build/script/tecnhode.js 中,接着 rev 根据文件内容为其生成了唯一的标示 7add9650.technode.js
,最后,usemin 再把 build/index.html 中的 js 区块换成了 <script src="/script/7add9650.technode.js"></script>
。这就是我们采用的整个打包压缩过程。同理 css 也是如此。
发布
发布之前我们还需要做一些准备工作,我们需要让生产环境中访问的是打包压缩过的静态文件,express 为我们提供了一种区分开发环境和声场环境的方式:
app.configure('development', function () {
app.set('staticPath', '/static')
})
app.configure('production', function () {
app.set('staticPath', '/build')
})
app.use(express.static(__dirname + app.get('staticPath')))
如果我们运行 node app.js
express 木人采用的是 development 环境,我们可以使用 NODE_ENV=production node app.js
来启用生产环境的配置,我们这里的做法很简单,将静态文件的路径指定到编译后的/build 目录即可。
至于如何部署到线上,我不再多提,大家可以参考 《使用 Express + MongoDB 搭建多人博客》番外篇之——部署到 Heroku 将 TechNode 部署到 heroku 上。注意一下两点即可:
- 修改 config.js 中的 mongdb 配置,修改成对应的你在 mongohq 的数据库,例如:
mongodb://<user>:<password>@troup.mongohq.com:10046/technode
- 修改 heroku 对应的 Procfile 文件,添加
web: NODE_ENV=production node app.js
,让 TechNode 已生产模式启动。
大家可以访问 http://technode.herokuapp.com/ (也可以通过 www.technode.im 来访问),测试我部署在 heroku 上的 TechNode。
聊天室之旅结束啦!
到这里,我们的聊天室之旅告一段落。TechNode 虽然还有很多不足之处,但是我希望,你看完这几章之后,能够有所收获。相信你现在一定能够使用 Node.js、socket.io、Angular.js 等等这些现在炙手可热的框架来快速搭建一个 Web 应用吧!赶紧计划自己的下一个项目吧,如果有进展,可别忘了告诉我!
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论