Chia Yu Pai

Front-End, HTML5, Javascript, CSS3

Posts match “ CoffeeScript ” tag:

WebRTC Peer to Peer With Socket.io

| Comments

知識

WebRTC 是最新的瀏覽器應用之一,提供 Web Application Client 彼此互連的功能實作基礎
在標準之中,主要有三大 API 項目

  1. MediaStream
  2. RTCPeerConnection
  3. RTCDataChannel

其中又以 RTCPeerConnection 為整個協定的基礎
WebRTC 就像是稍早的 WebSocket 一般,不同於 stateless HTTP 協定
而是較像是傳統語言的 Socket,使用較精簡的協定內容,讓反應速度可以提升到很高的水準
另外本文所有語法使用 Coffeescript 撰寫,相關訊息請洽官方網站

API

MediaStream

這個 API 其實就是 getUserMedia 的正式名稱,提供開發著一個統一的介面存取使用者的多媒體介面
舉凡視訊鏡頭、麥克風及螢幕節圖等,支援度在各大瀏覽器也算中上
需要多媒體應用的可以多加研究,在此因為非關 WebRTC 核心部分就不多提了
詳細用法可參考 MDN 的說明
Navigator.getUserMedia - MDN

RTCPeerConnection

如同其名,這個物件算是整個協定的核心項目
因為過去 WebRTC 的標準還沒訂定,網路上很多各種不同的資訊十分凌亂
基本上,當兩台 Client 需要做直接連線時,理想上我們可以直接透過 IP 互連的方式
取得最快的 Route,但是因為網際網路實際的發展並非如此
在我們的電腦前眾多的 NAT / IP Router 等打亂了簡單的 IP 直連可能
於是我們必須借助 STUN Server 替我們記錄真實的轉送流程
才能正常送到目標的對象手上,目前 Google 有提供一組免費的 STUN Server 如下
stun:stun.l.google.com:19302

除了 IP Route 的問題之外
我們要如何找到對方存在呢?使用 DHT 等無中央式的網路似乎可行
但是這樣會大幅拉長配對時間,就失去的 WebRTC 相較於傳統 HTTP 連線的最大優勢:速度
因此我們還是透過中央伺服器的方式協助我們的使用者做交換訊息的工作
在此就以 Socket.io 作為範例,解釋整個流程

首先,WebRTC 的 Client 端會分為 Offer 與 Answer 兩種角色
如同字面上意思 Offer 端為連線的發起者,Answer 則為被動方的角色
在啟用一個新的 WebRTC Connection 時,我們必須建立一個 Offer,並取得它的 Description

RTCPeerConnection = RTCPeerConnection or webkitRTCPeerConnection or mozRTCPeerConnection
# 提供 STUN Server
iceServers =
  iceServers: [{
    url: 'stun:stun.l.google.com:19302'
  }]

# 啟用 RTPDataChannel
optionalRtpDataChannels =
  optional: [{
    RtpDataChannels: true
  }]

# 建立一個新的 Connection
peer = new RTCPeerConnection iceServers, optionalRtpDataChannels

# 建立 Offer 方法
createOffer = ->
  # 建立 DataChannel 接管資料的傳輸
  dataChannel = peer.createDataChannel 'RTCDataChannel',
    reliable: false
  
  # 這是一些事件的監聽與錯誤檢測
  channel.onmessage = (event)->
    console.debug "offer receive a message #{event.data}"

  channel.onopen = ->
    console.debug 'channel open'

  channel.onclose = (e)->
    console.error e

  channel.onerror = (e)->
    console.error e
   
  # 透過 Peer 建立 Offer
  peer.createOffer (sessionDescription)->
    # 設定本地端 Description
    peer.setLocalDescription sessionDescription
    
    # 傳送回 Server 本地端的 Description
    socket.emit 'sourceOffer', 
      description: sessionDescription  
  , null, {}

createOffer 提供三個參數,首先當然是產生了 Offer Description 後的 callback 事件
第二個是如果產生失敗時的 Error callback,最後則是對於多媒體的限制與選項 MediaConstraints
別忘了,伺服器端需要使用 Socket.io 轉送這個 Description 給目標連線對象

接著,接收方則需要建立一個 Answer 去準備相關的回應

socket.on 'sourceOffer', (data)->
  # 接收時記得把原始物件轉換為 RTCSessionDescription 的實例
  answerSDP = new RTCSessionDescription data.description
  createAnswer answerSDP

# 建立 Answer 方法
createAnswer = (offerSDP)->
  # 一樣建立 DataChannel 接管資料的傳輸
  dataChannel = peer.createDataChannel 'RTCDataChannel',
    reliable: false

  ###
  略過事件監聽與除錯部分,請參考 Offer 的設定
  ###

  # 設定好遠端的 Description
  peer.setRemoteDescription offerSDP
  
  # 建立 Answer
  peer.createAnswer (sessionDescription)->
    # 一樣先儲存 Local Description
    peer.setLocalDescription sessionDescription

    # 把本地端的 Description 丟回給 Offer
    socket.emit 'sourceAnswer', 
      description: sessionDescription.description
  , null, {}

最後我們在回到 Offer 發起端

socket.on 'sourceAnswer', (data)->
  # 一樣轉成 RTCSessionDescription
  answerSDP = new RTCSessionDescription data.description
  # 設定為遠端的 Description
  peer.setRemoteDescription answerSDP

到這裡,我們已經完成基本的辨識
offer.localDescription = answer.remoteDescription
answer.localDescription = offer.remoteDescription
兩方各持有對方的 Description 描述

接著我們要使用 STUN Server 給我們的 candidate 建立連線
所以需要在 peer 加入監聽事件,將拿到的 candidate 轉送給另一方

# ICE
peer.onicecandidate = (event)->
  return if !peer or !event or !event.candidate
  socket.emit 'candidate', event.candidate

所以當然也有接收方

socket.on 'candidate', (data)->
  # 將 candidate 給 peer 讓他找到我們的路徑
  peer.addIceCandidate new RTCIceCandidate
    sdpMLineIndex: data.candidate.sdpMLineIndex
    candidate: data.candidate.candidate

至此連線就建立完成
複習一下,
Offer -> Answer -> Offer -> Offer Candidate <-> Answer Candidate

RTCDataChannel

最後其實關於它的基本東西都說完了
在連線建立起來後它負責的就是傳輸的部份了
透過 send 方法可以自由的在雙方傳送資訊
對方就會在 channel.onmessage 收到囉!


更多的問題歡迎留言討論
也可參考 W3C 官方的標準說明書

Yo Angular with CoffeeScript / Stylus / Jade

| Comments

2014-04-11 更新

我 Fork 出來一個可以自動產生的版本了
generator-angular-jade-stylus (GitGub)


yeoman 是十分方便的前端網頁產生模式,透過 angular generator 可以快速的產生良好架構的前端網頁,但 yo angular 預設支援 SASS / pure HTML 跟我的開發習慣不太吻合,研究了一下在 Grunt 加入自動編譯 Stylus / Jade 的方法。

此外,如果要在 generator-angular 啟用 CoffeeScript 請記得在 yo angular [appName] 就要加入 --coffee 參數,才會產生 Coffeescript 版的範例網頁唷。

generator-angular (GitHub)

首先我們需要載入 Grunt Contibutor for Stylus / Jade

npm install grunt-contrib-jade -D
npm install grunt-contrib-stylus -D

接著修改 Gruntfile.js 的配置

於最上方引用外部的 Task 並註冊給 compass

module.exports = function (grunt) {
  grunt.loadNpmTasks('grunt-contrib-stylus');
  grunt.loadNpmTasks('grunt-contrib-jade');
  grunt.registerTask('compass', ['stylus']);
  
  grunt.initConfig({
  //.....

}  

之後就要註冊我們自己的 Task Config

grunt.initConfig({
  jade: {
    dist: {
      options: {
        pretty: true
      },
      files: [{
        expand: true,
        cwd: '<%= yeoman.app %>',
        dest: '.tmp',
        src: ['*.jade', 'views/{,*/}*.jade'],
        ext: '.html'
      }]
    }
  },
  
  /*
  這裡我使用了 compass 協助我合併多個 stylus 的檔案,節省在引用時的 request 數量
  files 的 key 就是合併後的檔案位置 .tmp 是預設的 generator-angular test assets folder
  value 則是 source sheets 的陣列並可使用通用字符
  */
  
  stylus: {
    compile: {
      options: {
        compress: true,
        paths: ['node_modules/grunt-contrib-stylus/node_modules']
      },
      files: {
        '.tmp/styles/main.css': ['<%= yeoman.app %>/styles/*.styl']
      }
    }
  },
  
  /*
  為了可以即時透過 livereload 檢視修改的檔案,也在 watch 中加入監聽條件
  */
  
  watch: {
    jade: {
      files: ['<% yeoman.app %>/*.jade'],
      tasks: ['jade']
    },
    stylus: {
      files: ['app/styles/**/*.styl'],
      tasks: ['stylus']
    },
    // ...

  },
  
  /*
  接著因為我把 HTML 改成 Jade 編譯,所以原本 app 資料夾中僅有 source 檔案,build 過的檔案已經改到 .tmp 中了,要使 bower-install / usemin 可以正常運作在 build 時合併檔案,需要修改一些預設路徑。
  */
  
  'bower-install': {
    app: {
      html: '.tmp/index.html',
      ignorePath: '<%= yeoman.app %>/'
    }
  },
  
  useminPrepare: {
    html: '.tmp/index.html',
    options: {
      dest: '<%= yeoman.dist %>'
    }
  },
  
  // ...

最後只要在 grunt serve / build 的 task 裡面加入 jade 的 call 就可以囉

grunt.registerTask('serve', function (target) {
  if (target === 'dist') {
    return grunt.task.run(['build', 'connect:dist:keepalive']);
  }
  
  grunt.task.run([
    'clean:server',
    'jade',
  // ...

  ]);
});

// jade 的位置很重要,你必須在 usemin 之前先將 html 編譯完成,才可以正常的進行最小化以及 bower-install 的動作


grunt.registerTask('build', [
  'clean:dist',
  'jade',
  // ...

]);

以上就是在 generator-angular 加入 Jade / Stylus 的方法,這樣的修改方法會遇到個問題,就原本的用法 yo angular:controller 等新增動作時,會自動加入 index.html 的引用句會無法使用,因此你將必須手動將

至於修改這個 Bug 的方法,必須直接修改 generator 的 source 本身,這樣每當 generator 更新版本都需要再次修改,等於要維護一個新的 fork 在時間有限的情況下就先將就著用囉,詳細改法有興趣可以來信討論 :)

--
附上一個修改後的 index.jade 方便大家直接套用

doctype html
<!--[if lt IE 7]>
<html class="no-js lt-ie9 lt-ie8 lt-ie7">
<![endif]-->
<!--[if IE 7]>
<html class="no-js lt-ie9 lt-ie8">
<![endif]-->
<!--[if IE 8]>
<html class="no-js lt-ie9">
<![endif]-->
<!--[if gt IE 8]><!-->
<html class="no-js">
<!--<![endif]-->
head
  meta(charset="utf-8")
  meta(http-equiv="X-UA-Compatible", content="IE=edge")
  title
  meta(name="description", content="")
  meta(name="viewport", content="width=device-width")
  // Place favicon.ico and apple-touch-icon.png in the root directory
  // build:css styles/vendor.css
  // bower:css
  link(rel="stylesheet", href="bower_components/bootstrap/dist/css/bootstrap.css")
  // endbower
  // endbuild
  // build:css({.tmp,app}) styles/main.css
  link(rel="stylesheet", href="styles/main.css")
  // endbuild
body(ng-app="cominfinitibeatstyletripplacewebApp")
  <!--[if lt IE 7]>
  p.browsehappy
    | You are using an 
    strong outdated
    |  browser. Please 
    a(href="http://browsehappy.com/") upgrade your browser
    |  to improve your experience.
  <![endif]-->

  // Add your site or application content here 
  div.container(ng-view="")

  // Google Analytics: change UA-XXXXX-X to be your site's ID 
  script.
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
    (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
    })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

    ga('create', 'UA-XXXXX-X');
    ga('send', 'pageview');

  <!--[if lt IE 9]>
  script(src="bower_components/es5-shim/es5-shim.js")
  script(src="bower_components/json3/lib/json3.min.js")
  <![endif]-->

  // build:js scripts/vendor.js 
  // bower:js 
  script(src="bower_components/jquery/jquery.js")
  script(src="bower_components/angular/angular.js")
  script(src="bower_components/bootstrap/dist/js/bootstrap.js")
  script(src="bower_components/angular-resource/angular-resource.js")
  script(src="bower_components/angular-cookies/angular-cookies.js")
  script(src="bower_components/angular-sanitize/angular-sanitize.js")
  script(src="bower_components/angular-route/angular-route.js")
  // endbower 
  // endbuild 

  // build:js({.tmp,app}) scripts/scripts.js 
  script(src="scripts/app.js")
  script(src="scripts/controllers/main.js")
  // endbuild 

最下方的 // endbuild 之上就是我提到要自行加入 script tag 的地方哦。