文章出處

金股棒架構分析與發布

在本文中,我們將一步步編寫出來聊天室PC端和APP端重新做一次梳理,以及進行一些優化,讓整個項目更容易理解和擴展。其次我們還會介紹一些前端流行的工具,幫助我們構建項目,便于發布。

項目結構

目前PC端和APP端項目的架構大致相似,如下圖:

TechNode Structure

箭頭代表了讀取數據的流向,服務端和客戶端基本上都分為三層:

  • 服務端:在MongoDB和mongoose之上,我們添加了一層模型的controller,這一層直接處理一些業務相關的邏輯;在這之上,我們直接通過http API或者socket.io將所提供的接口暴露出來;這一塊的代碼全部寫在了app.js中;
  • 客戶端:針對不同的組件或者頁面,我們對應了不同的controller,而這些controller都是通過$http或者socket 服務直接于服務端通信的;各個controller之間共享數據很困難。

基于上面的問題,我們做出以下調整:

  • 將服務端邏輯從app.js分拆為http和socket兩個服務中;
  • 在客戶端提供一個統一的數據接口層,向上為controller提供數據服務,向下和服務端通信,同步數據。

新的結構應該像下面這樣:

TechNode Structure

分拆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內的處理,與第三章的基本無異,不再細說。

客戶端緩存

為什么需要客戶端緩存?有兩點原因:

  1. 在第三章的實現中,在房間列表和房間切換時,controller都會通過socket從服務端重新獲取房間列表或房間;
  2. 在第三章的實現中,我們無法在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提供的組件,$q$cacheFactory

$q

$q是Angular對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?即這塊緩存是有大小的(避免緩存開銷過大,影響網易性能),并且這塊緩存使用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' ] } ] } }

它為我們生成了本來需要手動編寫的其他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中,我們既可以將模板文件單獨放在不同的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也是如此。

發布TechNode

發布之前我們還需要做一些準備工作,我們需要讓生產環境中訪問的是打包壓縮過的靜態文件,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目錄即可。

  • 修改config.js中的MongoDB配置,修改成對應的你在MongoHQ的數據庫,例如:mongodb://technode:technode@troup.mongohq.com:10046/technode
  • 修改開發環境對應的Procfile文件,添加web: NODE_ENV=production node app.js,讓TechNode以生產模式啟動。

文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()