gates1deの備忘録

主に開発でひっかかったこと, ハマったことを記録します

herokuにビデオチャットをデプロイしてみた

やっと夏休みに入って一段落したので記事を(連続で)書いていこうかと思います.

最近はインターンの書類選考や面接等を受けているんですが, 現在インターン中でもあります.
インターン先ではWebRTCを使ってアプリを開発し, サーバと連携させるというのがテーマになっています.
その一つの例としてビデオチャットを作ろうということになりました.

この記事以降で紹介しますが, Android4.4(現時点で最新)のWebViewでは, カメラやマイクを使って自分と相手の動画・音声といったメディアを取得できないので, Webアプリとして実装しました.

話は少し飛びましたがherokuでシグナリングサーバを立ててWebRTCを実現させる方法を紹介していきます.

※インストールなどはMac OS X(Mavericks)で行っています. また, WebRTCの仕組みはここなどを参照してください.

それでは本編に入ります.

Node.jsのインストール

まずはNode.jsをインストールする必要があります.
インストールしていない方は以下の手順でインストールしてください.

$ git clone git://github.com/creationix/nvm.git ~/.nvm
$ source ~/.nvm/nvm.sh
$ nvm install 0.10.26

インストールされたか確認するために$ node -v とかやってみて「v0.10.26」と表示されたらNode.jsのインストールは完了です.

nvm(Node Version Manager)の設定

nvmを次回のターミナル起動時にもいつも通り使えるようにする設定です.
以下の手順になります.

$ nvm alias default v0.10.26
$ vim ~/.bash_profile
# 以下を追記
if [[ -s ~/.nvm/nvm.sh ]];
  then source ~/.nvm/nvm.sh
fi

npm(Node Package Manager)のインストールと設定

ノードのパッケージマネージャをインストール&設定します.
以下の手順で行ってください.

$ curl http://npmjs.org/install.sh | sh

どのディレクトリでも使えるように以下の手順を行い, パスを通します.

$ vim ~/.bash_profile
# 以下を追記
export PATH=$HOME/.npm/bin:$PATH
export NODE_PATH=$HOME/.npm/libraries:$NODE_PATH
export MANPATH=$HOME/.npm/man:$MANPATH

これも確認のために「npm -v」をやってインストールされたか確認してみてください.

expressインストール

expressはNode.jsのWebアプリを開発するためのフレームワークです.
まずは以下の手順でexpressをnpmでインストールしましょう.

$ npm install -g express-generator

Webアプリを自動生成する

次はexpressを使ってWebアプリを自動生成してみましょう.
以下の手順でやっていきます.

$ express video_chat
$ cd video_chat && npm install
$ npm install socket.io

あとうろ覚えで申し訳ないのですが, socket.ioもnpmを使ってインストールしないといけないので, 最後の行もやっておいてください.
これで一応Node.jsのWebアプリは出来たことになります.
とりあえずvideo_chatディレクトリに移動してファイルを見てみると, いくつか用意されていると思います.
次以降はビデオチャットのプログラムを書いていきましょう.

app.jsの編集

WebRTCを実現するためにシグナリングサーバを用意しなければなりません. その役割をapp.jsにやってもらいます.
app.jsを以下のように記述してみましょう.

var http = require('http');
var express = require('express');
var port = process.env.PORT || 3002;
var app = express();

app.get('/', function(req, res) {
  res.sendfile(__dirname + '/videoChat.html'); // 次に作るファイルへのパスです.
});
app.set('port', port);

var server = http.createServer(app).listen(app.get('port'), function(){
  console.log('Express server listening on port ' + app.get('port'));
});
var io = require('socket.io').listen(server);
console.log((new Date()) + " Server is listening on port " + port);
 
io.sockets.on('connection', function(socket) {
  socket.on('message', function(message) {
    socket.broadcast.emit('message', message);
  });
    
  socket.on('disconnect', function() {
    socket.broadcast.emit('user disconnected');
  });
});

保存して終了します.
何故かrequire('http')してるんですが, どうやら必要ないっぽい…
これでも一応動作してるので自分はこんなかんじにしてますが, こういったソースコードはどこにでもありますので探してみてください(;´Д`)

htmlの作成

見た目の部分となるhtmlファイルを作成します.
上のプログラムの7行目にvideoChat.htmlと書いていますが, 今回はこのファイルを作ることにします.
htmlファイルの名前は自由ですが, signaling.jsの中身もかえてください.
プログラムはここを参考にさせて頂いてます.

<!DOCTYPE html>
<html>
<head>
  <title>WebRTC 1 to 1 signaling</title>  
</head>
<body>
  <button type="button" onclick="startVideo();">Start video</button>
  <button type="button" onclick="stopVideo();">Stop video</button>
  &nbsp;&nbsp;&nbsp;&nbsp;
  <button type="button" onclick="connect();">Connect</button>
  <button type="button" onclick="hangUp();">Hang Up</button>
  <br />
  <div>
   <video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video>
   <video id="remote-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video>
  </div>
  
  <p>
   SDP to send:<br />
   <textarea id="text-for-send-sdp" rows="5" cols="100" disabled="1">SDP to send</textarea>
  </p>
  <p>
   SDP to receive:<br />
   <textarea id="text-for-receive-sdp" rows="5" cols="100"></textarea><br />
   <button type="button" onclick="onSDP();">Receive SDP</button>
  </p>
  
  <p>
   ICE Candidate to send:<br />
   <textarea id="text-for-send-ice" rows="5" cols="100" disabled="1">ICE Candidate to send</textarea>
  </p>
  <p>  
   ICE Candidates to receive:<br />
   <textarea id="text-for-receive-ice" rows="5" cols="100"></textarea><br />
   <button type="button" onclick="onICE();">Receive ICE Candidates</button>
  </p>
  
  <!---- socket ------>
  <script src="/socket.io/socket.io.js"></script>
  
  <script>
  var localVideo = document.getElementById('local-video');
  var remoteVideo = document.getElementById('remote-video');
  var localStream = null;
  var peerConnection = null;
  var peerStarted = false;
  var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':true }};

  
  // ---- socket ------
  // create socket
  var socketReady = false;
  var port = process.env.PORT || 3002;
  var socket = io.connect('/');
  // socket: channel connected
  socket.on('connect', onOpened)
        .on('message', onMessage);

  function onOpened(evt) {
    console.log('socket opened.');
    socketReady = true;
  }

  // socket: accept connection request
  function onMessage(evt) {
    if (evt.type === 'offer') {
      console.log("Received offer, set offer, sending answer....")
      onOffer(evt);	  
    } else if (evt.type === 'answer' && peerStarted) {
      console.log('Received answer, settinng answer SDP');
	  onAnswer(evt);
    } else if (evt.type === 'candidate' && peerStarted) {
      console.log('Received ICE candidate...');
	  onCandidate(evt);
    } else if (evt.type === 'user dissconnected' && peerStarted) {
      console.log("disconnected");
      stop();
    }
  }

  
  
  // ----------------- handshake --------------
  var textForSendSDP = document.getElementById('text-for-send-sdp');
  var textForSendICE = document.getElementById('text-for-send-ice');
  var textToReceiveSDP = document.getElementById('text-for-receive-sdp');
  var textToReceiveICE = document.getElementById('text-for-receive-ice');
  var iceSeparator = '------ ICE Candidate -------';
  var CR = String.fromCharCode(13);
  
  function onSDP() {
    var text = textToReceiveSDP.value;
	var evt = JSON.parse(text);
	if (peerConnection) {
	  onAnswer(evt);
	}
	else {
	  onOffer(evt);
	}
	
	textToReceiveSDP.value ="";
  }  
  
  //--- multi ICE candidate ---
  function onICE() {
    var text = textToReceiveICE.value;
	var arr = text.split(iceSeparator);
	for (var i = 1, len = arr.length; i < len; i++) {
      var evt = JSON.parse(arr[i]);
	  onCandidate(evt);
    }

	textToReceiveICE.value ="";
  }
  
  
  function onOffer(evt) {
    console.log("Received offer...")
	console.log(evt);
    setOffer(evt);
	sendAnswer(evt);
	peerStarted = true;  // ++
  }
  
  function onAnswer(evt) {
    console.log("Received Answer...")
	console.log(evt);
	setAnswer(evt);
  }
  
  function onCandidate(evt) {
    var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate});
    console.log("Received Candidate...")
	console.log(candidate);
    peerConnection.addIceCandidate(candidate);
  }

  function sendSDP(sdp) {
    var text = JSON.stringify(sdp);
	console.log("---sending sdp text ---");
	console.log(text);
	textForSendSDP.value = text;
	
	// send via socket
	socket.json.send(sdp);
  }
  
  function sendCandidate(candidate) {
    var text = JSON.stringify(candidate);
	console.log("---sending candidate text ---");
	console.log(text);
	textForSendICE.value = (textForSendICE.value + CR + iceSeparator + CR + text + CR);
	textForSendICE.scrollTop = textForSendICE.scrollHeight;
	
	// send via socket
	socket.json.send(candidate);
  }
  
  // ---------------------- video handling -----------------------
  // start local video
  function startVideo() {
	navigator.webkitGetUserMedia({video: true, audio: false},
    function (stream) { // success
      localStream = stream;
      localVideo.src = window.webkitURL.createObjectURL(stream);
      localVideo.play();
	  localVideo.volume = 0;
    },
    function (error) { // error
      console.error('An error occurred: [CODE ' + error.code + ']');
      return;
    }
	);
  }

  // stop local video
  function stopVideo() {
    localVideo.src = "";
    localStream.stop();
  }

  // ---------------------- connection handling -----------------------
  function prepareNewConnection() {
    var pc_config = {"iceServers":[]};
    var peer = null;
    try {
      peer = new webkitRTCPeerConnection(pc_config);
    } catch (e) {
      console.log("Failed to create peerConnection, exception: " + e.message);
    }

    // send any ice candidates to the other peer
    peer.onicecandidate = function (evt) {
      if (evt.candidate) {
        console.log(evt.candidate);
        sendCandidate({type: "candidate", 
                          sdpMLineIndex: evt.candidate.sdpMLineIndex,
                          sdpMid: evt.candidate.sdpMid,
                          candidate: evt.candidate.candidate}
		);
      } else {
        console.log("End of candidates. ------------------- phase=" + evt.eventPhase);
      }
    };

    console.log('Adding local stream...');
    peer.addStream(localStream);

    peer.addEventListener("addstream", onRemoteStreamAdded, false);
    peer.addEventListener("removestream", onRemoteStreamRemoved, false)

    // when remote adds a stream, hand it on to the local video element
    function onRemoteStreamAdded(event) {
      console.log("Added remote stream");
      remoteVideo.src = window.webkitURL.createObjectURL(event.stream);
    }

    // when remote removes a stream, remove it from the local video element
    function onRemoteStreamRemoved(event) {
      console.log("Remove remote stream");
      remoteVideo.src = "";
    }

    return peer;
  }

  function sendOffer() {
    peerConnection = prepareNewConnection();
    peerConnection.createOffer(function (sessionDescription) { // in case of success
      peerConnection.setLocalDescription(sessionDescription);
      console.log("Sending: SDP");
      console.log(sessionDescription);
      sendSDP(sessionDescription);
    }, function () { // in case of error
      console.log("Create Offer failed");
    }, mediaConstraints);
  }

  function setOffer(evt) {
    if (peerConnection) {
	  console.error('peerConnection alreay exist!');
	}
    peerConnection = prepareNewConnection();
    peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
  }
  
  function sendAnswer(evt) {
    console.log('sending Answer. Creating remote session description...' );
	if (! peerConnection) {
	  console.error('peerConnection NOT exist!');
	  return;
	}
	
    peerConnection.createAnswer(function (sessionDescription) { // in case of success
      peerConnection.setLocalDescription(sessionDescription);
      console.log("Sending: SDP");
      console.log(sessionDescription);
      sendSDP(sessionDescription);
    }, function () { // in case of error
      console.log("Create Answer failed");
    }, mediaConstraints);
  }

  function setAnswer(evt) {
    if (! peerConnection) {
	  console.error('peerConnection NOT exist!');
	  return;
	}
	peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
  }
  
  // -------- handling user UI event -----
  // start the connection upon user request
  function connect() {
    if (!peerStarted && localStream && socketReady) { // **
	//if (!peerStarted && localStream) { // --
      sendOffer();
      peerStarted = true;
    } else {
      alert("Local stream not running yet - try again.");
    }
  }

  // stop the connection upon user request
  function hangUp() {
    console.log("Hang up.");
    stop();
  }

  function stop() {
    peerConnection.close();
    peerConnection = null;
    peerStarted = false;
  }

  </script>
</body>
</html>

最初の方も言っていたように, 次以降の記事でjQuery Mobileでのビデオチャットアプリについてもう一回ソースコードも乗せる予定ですが, 自分なりにスッキリさせておいたのを載せますが, 今回はコレで^^;

package.jsonの編集

忘れていたんですが, このpackage.jsonというのはRailsでいうGemfileのようなもので, ここには自分の設定や必要なパッケージ等を書き込みます.
そうするとnpm installしたときに必要なパッケージが自動的に入るというものなんですが, もうローカルでは入れましたので今のところは必要性を感じないんですが, herokuにデプロイするときに重要なので記述しましょう.
以下の手順でまず作っていきましょう.

$ npm init

name: (アプリの名前)
version: (0.0.0) 
description: 
entry point: (app.js) 
test command: 
git repository: 
keywords: 
author: (自分の名前やニックネーム)
license: (BSD) MIT

{
  "name": "XXX",
  "version": "0.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": "",
  "author": "YYY",
  "license": "MIT"
}

といった感じになると思うんですが, 少し書き換えます.

{
  "name": "XXX",
  "version": "0.0.0",
  "description": "",
  "author": "YYY",
  "license": "MIT",
  "dependencies": {
    "express": "^3.14.0",
    "logfmt": "^1.1.2",
    "ws": "0.4.x",
    "socket.io": "1.0.6"
  },
  "engines": {
    "node": "0.10.x"
  }
}

多分これでもいけると思います.
herokuにデプロイするときに, heroku側で自動的にdependenciesの中に記述してあるパッケージをインストールしてくれるはず…です!笑
(wsとかlogfmtとかいらないとは思います笑)

Procfileの作成

これはとりあえず, 「herokuが最初に実行するコマンドを指定する」程度に覚えていただければよいでしょう.
以下のように記述します.

web: node signaling.js

.gitignoreの作成

ローカルではnode_modulesが必要なんですが, herokuにはあげる必要がないので, .gitignoreを作ってあげないようにしましょう.

$ vim .gitignore
# 以下を記述
node_modules

さて, これで一通りアプリは作り終えたので次から本題の(笑)herokuにデプロイする手順を示します.

herokuへのデプロイ

前の方で述べましたが, githubとかのソースコード管理を使ったことがある人には非常に簡単です.
まずherokuに登録していない人は登録しましょう.
登録後は恐らく「Create your app」的な感じのものが表示されると思います.
そこで一旦appを作っておいて, 作った後に出てくる「git URL」が必要になります.
最初に表示されますが, それ以降はappの「settings」で見れます.
これらの手順は直感でも行けると思うので説明を省略します.

また, herokuのサイトで「toolbelt」をダウンロード&インストールしておいてください.
これも簡単なので詳細は省きまして, その後の設定の手順はここを参考にしてください.

そしたら以下の手順でデプロイしていきます.
gitはインストールしてある前提です(妥協).

$ git init
$ git remote add origin <自分のgit URL>
$ git add .
$ git commit -m "first commit"
$ git push origin master

待っているとずらーっと書かれますが, 色々と必要なモジュールを作成しているところです.
終了したら,

$ heroku open

とすると自動的にブラウザを開いて自分のappを開いてくれます.
※ここで注意が必要なのですが, パソコンの音をミュートにしておかないとめちゃくちゃハウリングうるさいので音はミュートにしておきましょう! それかイヤホンとか付けましょう!

以上で完了になりますが, うろ覚えな感じで書いてきましたので, 恐らくエラーは起こるでしょう!笑
でもそういったエラーを処理する力は初心者といえども必要になりますので頑張ってみましょう(;´∀`)笑


それでは今日はこの辺で!
Say good-bye!