HTML5Experts.jp https://html5experts.jp 日本に、もっとエキスパートを。 Wed, 27 Jul 2016 08:28:55 +0000 ja hourly 1 http://wordpress.org/?v=4.2.9 Webブラウザで高速な演算を可能にする低水準言語asm.jsと、WebAssembly詳解ーasm.jsの仕組みとコーディング例 https://html5experts.jp/chikoski/18980/ https://html5experts.jp/chikoski/18980/#comments Mon, 25 Jul 2016 00:06:51 +0000 https://html5experts.jp/?p=18980 連載: 低水準言語asm.jsとWebAssembly詳解 (2)

連載の第1回目は asm.jsの紹介と、asm.jsが導入された背景を概観しました。

Just in Timeコンパイルによって高速にJavaScriptを実行できるようになりましたが、立ち上がりが遅い、やり直しが発生する、コンパイルによって一時的に負荷が向上する、といった問題が残されています。

これを解決するためにプログラムの実行を行うより前にネイティブコードへとコンパイルするAhead of Timeコンパイルを導入したいのですが、JavaScriptは柔軟すぎて効率の良いネイティブコードを出力することが難しい、という問題がありました。

asm.jsはこの問題に一定の解をあたえるものとなります。今回はそのasm.jsがどのようなものなのか、JavaScriptの関数を asm.js化しながら解説していきます。

asm.jsがコンパイルされるまで

前述したとおりasm.jsで記述されたプログラムは、実行以前にコンパイルされます。コンパイルは下図のような過程で行われます。

asm.jsがコンパイルされるまで。意味解析とリンク時のチェックが行われ、失敗すると通常のJSとして実行される。

ソースコードが字句解析、構文解析されてAST(Abstract Syntax Tree:抽象構文木)になるところまでは一緒ですが、その後意味解析が行われます。この意味解析でそれぞれの式や変数、関数の型がチェックされます。

意味解析が終了後、プログラムはコンパイルされネイティブコードが出力されます。このネイティブコードはメモリ上に展開されたあと、プログラム中で利用しているJavaScriptの関数やasm.jsに用意されている標準ライブラリとのリンクが行われます。

意味解析とリンク、この2つに失敗する場合もあります。プログラム中に型エラーが発見された場合は前者の失敗します。 後者はメソッドの呼び出しや、JSにエキスポートできない種類のデータを引数に指定した関数呼び出しを行った場合に失敗します。

このような場合、プログラムはasm.jsとして処理されるのではなく、通常のJSとして処理されます。asm.jsがJSのサブセットであることによって、このようなフォールバックも可能になっています。

asm.jsモジュール

asm.jsで書かれたプログラムとして事前コンパイルされる際の単位は、モジュールです。ファイル単位でコンパイルが行われるわけではないので、1つのJSファイルのうち高速化が必要な部分をasm.jsで書き、それ以外の部分は通常のJSとして書くといったことが可能です。

asm.jsのモジュールは、次のようにCやC++のソースコードと似た構造となっています。

function ModuleName(stdlib, ffi, heap){
"use asm";
 // (1) 外部からインポートするシンボルの宣言
 // (2) 関数宣言
 // (3) 関数表の宣言
 // (4) モジュールのエキスポート
}

1の部分では利用する標準ライブラリや、JSの関数、定数などのシンボルを列挙します。ちょうどCのextern宣言と同じような役割です。

2の部分で、それぞれの処理を関数として定義します。asm.jsではオブジェクトやクラスの定義が許されていません。そのため処理はクラスやオブジェクトとしてではなく、あくまで関数として定義します。

3の部分では同じ型の関数をまとめた表を定義できます。ちょうどCでの関数ポインタの機能を代替するものです。関数を直接呼び出すのではなく、この表を参照する形で呼び出すことで、呼び出す関数の振る舞いを変えられるので、多態性を持った関数を定義したい場合に有用です。

とはいえ、いまいちイメージがつかめないかと思います。そこで足し算を行うモジュールをasm.js化しながら、構成を見てゆきましょう。変更するのは以下のようなモジュールです。

function AddFunctions(){
  function add1(value){
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

このモジュールは次のように利用できます。

const module = AddFunctions();
const one = module.add1(0);    // 1
const two  = module.add1(one); // 2

asm.jsディレクティブ

asm.js化の第一歩は、ディレクティブの追加です。”use strict” ディレクティブをつけると、その関数はstrict modeで解釈されるのと同様に、”use asm”ディレクティブをつけることで、処理系はその関数をasm.jsのモジュール定義として処理します。

function AddFunctions(){
  "use asm";
  function add1(value){
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1,
  }
}

 型アノテーション

次にAOTを行うための型アノテーションを行います。型アノテーションは、TypeScriptなどのように型を直接記述する方法が一般的かと思いますが、JavaScriptとしても解釈できなくてはいけないasm.jsでは異なります。同値となるような式を追加することで、型情報を明示します。

明示的に型アノテーションを行う対象は次の3つです。

  • 関数の引数
  • 変数
  • 関数の返り値

これらの情報を元に、関数の型や式の型が決定されます。

引数に対する型アノテーション

引数に対する型アノテーションは、関数本体の先頭で行います。次の例では、add1の引数valueの型はintであることを示す型アノテーションが加わっています。value = value | 0; が型アノテーションを行っている部分です。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result;
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

引数で利用できる型はint, doubleの2つです。それぞれの型はは次の表のようにアノテーションします。

型アノテーション
int value = value | 0
doube value = +value
float value = f(value)

尚、外部で定義された関数呼び出しを行った場合は、floatとして解釈されます。

変数宣言

asm.jsでは、関数内で利用する変数に対しても型アノテーションを行います。これは宣言時に初期値として代入するを適切に選ぶ形で行います。整数値を代入すればintに、実数値の場合はdoubleとなります。

尚、1.0のような小数点以下の数字が0のものは実数値として扱われます。変数宣言に型アノテーションをつけると、先ほどまでのモジュールは以下のようになります。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result = 0; // intとして宣言
    result = value + 1;
    return result;
  }
  return {
    add1: add1
  }
}

返り値に対する型アノテーション

返り値に対して型アノテーションを行います。この情報と引数の型情報とを組み合わせて、関数の型が決定されます。

返り値で利用できるのはdouble, signed, float, そしてvoidの4つの型です。それぞれのアノテーション方法は以下の表の通りです。また即値を書く場合は、アノテーションは必要ありません。

アノテーション例 メモ
double return +result;
signed return result 0|
float return f(result) fは関数
void return;

asm.jsの型システム

これまでintやdoubleといった型を利用してきましたが、asm.jsで利用できる型を列挙し、それぞれの継承関係を示すと次の図となります。 矢印の元にある型は先の型を継承しています。

asm.js の型システム http://asmjs.org/spec/latest より引用

継承するということは、何かの制約が厳しくなっていくということです。asm.jsの型システムではnullを許容するか、実行時に割り当てられるレジスタがdoubleかintか、といった点での制約が厳しくなっていきます。

また、JavaScriptで定義されている関数に渡せる型も決まっています。背景色が薄い灰色になっているfixnum / signed / extern / double型のデータのみが許可されています。

型のキャスト

先ほどから変更しているプログラムは、意味解析に失敗します。それは次の部分に原因があります。

result = value + 1;

これはint+fixnumの計算を行い、その結果をint型の変数に代入しようとしています。型エラーの入り込む余地はなさそうに思えます。しかし、この加算の評価値の型はinterishとなっています。そのため、int型へinterish型の値を代入することになり、型エラーが起きるというわけです。

そこでキャストを行い、演算結果の型を明示します。この変更をおこないasm.jsの意味解析に成功するコードは次のようになります。

function AddFunctions(){
  "use asm";
  function add1(value){
    value = value | 0;
    var result = 0; // int として宣言
    result = (value + 1) | 0; // int へキャスト
    return result;
  }
  return {
    add1: add1
  }
}

JavaScriptとの組み込み

以上で、AddFunctionsモジュールをasm.js化することができました。これをJavaScriptのプログラムに組み込みんでいきます。

ここでは 上記で作成したAddFunctionsモジュールをJavaScript側から利用する方法と、AddFunctionsモジュール内で JavaScriptの関数を利用する方法について説明します。

JavaScriptからの利用

asm.jsのモジュールとJavaScriptのモジュールは、JavaScriptからみると区別できません。下記のようにJSのモジュールを呼ぶように利用できます。

const module = AddFunctions();
var one = module.add1(0);
var two = module.add1(one);

function add2(n){
  return module.add1(module.add1(n));
}

JavaScriptの関数をasm.jsから呼ぶには

まず、asm.jsの内部からJavaScriptで定義された関数を呼ぶことはできます。また、Mathオブジェクトの持っているいくつかのメソッドは、標準ライブラリ中の関数として提供されています。

これら関数への参照はモジュールを定義する関数の引数として与えます。例えば、AsmModuleにasm.jsモジュールが定義されている場合、次のように呼び出すことでJavaScriptの関数をasm.js内から呼び出せます。

const ffi = {
  put: n => console.log(n)
};

const module = AsmModule(window, ffi);

asm.jsで定義される関数はオブジェクトの解決ができません。そのため利用する関数はあらかじめ外部からインポートするシンボルとして宣言しておきます。

次の例では、標準ライブラリ中のMath.expとMath.log、そして自作関数であるputを外部からインポートするシンボルとして宣言しています。

function AsmModule(stdlib, ffi, heap){
  "use asm";
  var exp = std.lib.Math.exp;
  var log = std.lib.Math.log;

  var put = ffi.put;

これらの関数は、関数定義内でasm.js内部で定義された関数と同様に呼び出せます。ただ1点注意しなくてはならないのは、引数に渡すデータの型です。標準ライブラリ以外の外部関数に渡せるのはfixnum、signed、extern、doubleのいずれかです。 それ以外の値を渡すとリンクエラーとなり、通常のJSとして実行されます。演算の結果を適切にアノテーションすることで、リンクエラーを避けられます。

var value = 1;
put(value + 1); // リンクエラー
put((value + 1) | 0); // OK

ヒープの利用

asm.jsで定義される関数は、数値演算しかできません。また、オブジェクトの解決もできません。つまり、次のような関数は定義できないことになります。

function caesar(string, key){
  var result = "";
  for(let i = 0; i < string.length; i++){
    result += String.fromCharCode(a.charCodeAt(0)+key);
  }
  return result;
}

ところでC言語では文字列を数値の配列として扱います。この考えを応用すれば、asm.jsでも文字列を数値演算の範囲で扱えるようになります。

上記の関数をasm.jsに書き直すと以下のようになります。

function Caesar(stdlib, ffi, heap){
  "use asm";
  var HEAP = new stdlib.Int8Array(heap);

  function encrypt(key){
    key = key | 0;
    var i = 0;
    for(;(HEAP[i << 0 >> 0] | 0) != 0; i = i + 1 | 0){
      buffer[i << 0 >> 0] = ((buffer[i << 0 >> 0] | 0) + key) | 0;
    }
    return;
  }

文字列はHEAPというArrayBufferに格納されています。このArrayBufferはモジュールの定義時に引数として与えられます。 ArrayBufferのビューは標準ライブラリとして提供されているため、上記のようにモジュール内のHEAPを大域変数として宣言する際にビューもあわせて定義します。

HEAPの添字は、ビューの各要素の大きさに合わせてシフトする必要があります。シフトするビット数は、2を底として要素のバイトサイズのlogをとると求まります。

上記の例で利用しているInt8Arrayの場合、各要素の大きさは1バイトのため、0ビットシフトしています。ビューとシフトするビット数の対応は次の表を参照してください。

ビュー 要素のサイズ(バイト) シフトするビット数 ロード時の型 保存時の型
Uint8Array 1 0 intish intish
Int8Array 1 0 intish intish
Uint16Array 2 1 intish intish
Int16Array 2 1 intish intish
Uint32Array 4 2 intish intish
Int32Array 4 2 intish intish
Float32Array 4 2 float? floatish, double?
Float64Array 8 3 double? float?, double?

ArrayBufferを与えてモジュールの作成と関数の呼び出しを行うと、次のようなコードとなります。

気をつけなければならいのは、TypedArrayの大きさです。212 以上、224バイト未満の大きさになるようにするか、224バイトの整数倍の大きさになるようにしてください。そうしなければ、リンクに失敗してします。

これが原因でリンクに失敗した場合は、コンソールに適切なサイズが表示されます。それを参考に大きさを際設定すればようでしょう。

const heap = new Int8Array(0x10000)
const caesar = Caesar(window, {}, heap);
heap[0] = 72; heap[1] = 65; heap[2] = 76; // HALと設定
caesar.encrypt(1); // HALが1文字ずつシフトされる

まとめ

以上のように、JavaScriptと比べてasm.jsは随分と書きづらく、できることも限られています。ArrayBufferを駆使すればベクトルの計算も可能ですが、オブジェクトと平坦なArrayBufferとの相互変換を自分で実装しなくてはならず、なかなか骨が折れる作業であることは否めません。

その代わり得られる効果は絶大です。JITによって処理が重たくなることもなく、高速な実行が可能となります。またコンパイルされた結果はキャッシュされるため、2回目以降は高速に起動できるようになります。

とはいえ、手で書くのは骨が折れます。

「人間のやることではない」

「高級言語で実装したい」

そう思う方も多いでしょう。そのために用意されているツールがEmscriptenです。次回はEmscriptenを利用したC言語やC++で実装されたコードのasm.jsへの変換について解説します。

]]>
https://html5experts.jp/chikoski/18980/feed/ 0
シグナリングサーバーを動かそう ーWebRTC入門2016 https://html5experts.jp/mganeko/20013/ https://html5experts.jp/mganeko/20013/#comments Thu, 14 Jul 2016 00:45:36 +0000 https://html5experts.jp/?p=20013 連載: WebRTC入門2016 (3)

こんにちは! 2014年に連載した「WebRTCを使ってみよう!」シリーズのアップデート記事も3回目となりました。今回は、前回の「手動」で行ったP2P通信の準備を、自動で行えるようにしてみましょう。

シグナリングサーバーを立てよう

前回は手動でコピー&ペーストを行い、WebRTCのP2P通信を始めるために次の情報を交換しました。

  • SDP
  • ICE candidate

今回はこれを仲介するサーバー(シグナリングサーバー)を動かしてみましょう。方法として次の2つをご用意しました。

  • Node.jsを使ったシグナリングサーバー
  • Chromeアプリ

Node.jsを準備しよう

まず、WebSocketを使ってシグナリングを行う方法をご紹介します。WebSocketの扱いやすさから、ここではNode.jsを使います。(もちろん他の言語を使っても同様にシグナリングサーバーを作ることができます)こちらの公式サイトから、プラットフォームに対応したNode.jsを入手してインストールしてください。今回私は 4.4.7 LTSを使いました。

Node.jsのインストールが完了したら、次はWebSocketサーバー用のモジュールをインストールします。コマンドプロンプト/ターミナルから、 次のコマンドを実行してください。 ※必要に応じて、sudoなどをご利用ください。

npm install ws

以前の連載ではsocket.ioを使いましたが、今回はよりプリミティブなwsを使っています。

シグナリングサーバーを動かそう

次のコードを好きなファイル名で保存してください。(例えば signaling.js)

"use strict";

let WebSocketServer = require('ws').Server;
let port = 3001;
let wsServer = new WebSocketServer({ port: port });
console.log('websocket server start. port=' + port);

wsServer.on('connection', function(ws) {
  console.log('-- websocket connected --');
  ws.on('message', function(message) {
    wsServer.clients.forEach(function each(client) {
      if (isSame(ws, client)) {
        console.log('- skip sender -');
      }
      else {
        client.send(message);
      }
    });
  });
});

function isSame(ws1, ws2) {
  // -- compare object --
  return (ws1 === ws2);     
}

ポート番号は必要に応じて変更してください。起動するにはコマンドプロンプト/ターミナルから、 次のコマンドを実行します。

node signaling.js

シグナリングサーバーの動作はシンプルで、クライアントからメッセージを受け取ったら他のクライアントに送信するだけです。

Chromeアプリを使う場合は

場合によってはNode.jsをインストールして動かすのは、ハードルが高くて難しいケースもあるかもしれません。そんな人のために、Chromeアプリで「simple message server」というものを作ってみました。 simple_message_server_store
Chromeを利用したアプリとしてインストールし、アプリタブから起動して利用します。デスクトップ用のChromeが動く環境(Windows, MaxOS X, Linux, ChromeOS)で動くはずです。

起動すると、 ws://localhost:3001/ でクライアントからの接続を待ち受けます。※実装があまいので時々不安定になります。その場合は[restart]ボタンを押してリセットし、ブラウザもリロードして接続しなおしてください。

シグナリング処理を変更しよう

それでは前回の手動シグナリングのコードを、少しずつ変更していきましょう。まずWebSocketで用意したシグナリングサーバーに接続します。JavaScriptに次の処理を追加してください。(URLは使っているポートに合わせて修正してください)

let wsUrl = 'ws://localhost:3001/';
  let ws = new WebSocket(wsUrl);
  ws.onopen = function(evt) {
    console.log('ws open()');
  };
  ws.onerror = function(err) {
    console.error('ws onerror() ERR:', err);
  };

次に、WebSocketでメッセージを受け取った場合の処理を追加します。

ws.onmessage = function(evt) {
    console.log('ws onmessage() data:', evt.data);
    let message = JSON.parse(evt.data);
    if (message.type === 'offer') {
      // -- got offer ---
      console.log('Received offer ...');
      textToReceiveSdp.value = message.sdp;
      let offer = new RTCSessionDescription(message);
      setOffer(offer);
    }
    else if (message.type === 'answer') {
      // --- got answer ---
      console.log('Received answer ...');
      textToReceiveSdp.value = message.sdp;
      let answer = new RTCSessionDescription(message);
      setAnswer(answer);
    }
  };

JSONテキストからオブジェクトを復元し、typeに応じて前回用意したsetOffer()/setAnswer()を呼び出し、RTCPeerConnectionに渡しています。

SDPの送信

Offer/AnswerのSDPの送信も、WebSocket経由で行います。前回要したsendSdp()を次のように変更します。

function sendSdp(sessionDescription) {
    console.log('---sending sdp ---');

    textForSendSdp.value = sessionDescription.sdp;
    /*--- テキストエリアをハイライトするのを止める
    textForSendSdp.focus();
    textForSendSdp.select();
    ----*/

    // --- シグナリングサーバーに送る ---
    let message = JSON.stringify(sessionDescription);
    console.log('sending SDP=' + message);
    ws.send(message);
  }

SDPをJSONテキストに変換してWebSocketでシグナリングサーバーに送信しています。

実際に動かしてみよう

シグナリングサーバーを起動して、ChromeかFirefoxのウィンドウを2つ開いて修正したHTMLを読み込んでください。ChromeとFirefoxの間で通信することもできます。

Webサーバーを立てるのが難しい場合は、github pages にもサンプルを公開しているので、そちらで試すこともできます。その場合でもシグナリングサーバーは自分で用意する必要があるのでご注意ください。

(1) カメラの取得

両方のウィンドウで[Start Video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。
ws_signaling_startvideo

(2) 通信開始

どちらかのウィンドウで[Connect]ボタンを押します。(3)SDP(ICE candidateを含む)が自動で交換され、(4)ビデオ通信が始まります。
ws_signaling_connect

手動シグナリングに比べて操作がずっと簡単になりました。これなら実際に利用できそうですね。

Trickle ICE を使ってみよう

コピー&ペーストを手動で行う必要がなくなったので、ICE candidateを発生するたびに交換するTrickle ICE を使ってみましょう。流れはこのような形になります。
hand2016_trickle
すべてのICE candidateが出そろう前にP2P通信が確立する(ことがある)メリットがあります。(※2014年の記事では「すべてのICE candidateの交換が終わるとP2P通信が始まる」と書いていましたが、これは誤りです)

SDPをすぐに送信する

Offer SDP/Answer SDPを生成したら、すぐに相手に送るように変更します。

function makeOffer() {
    peerConnection = prepareNewConnection();
    peerConnection.createOffer()
    .then(function (sessionDescription) {
      console.log('createOffer() succsess in promise');
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      console.log('setLocalDescription() succsess in promise');

      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      sendSdp(peerConnection.localDescription); // <--- ここを加える

      // -- Vanilla ICE の場合には、まだSDPは送らない --
    }).catch(function(err) {
      console.error(err);
    });
  }

  function makeAnswer() {
    console.log('sending Answer. Creating remote session description...' );
    if (! peerConnection) {
      console.error('peerConnection NOT exist!');
      return;
    }
    
    peerConnection.createAnswer()
    .then(function (sessionDescription) {
      console.log('createAnswer() succsess in promise');
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      console.log('setLocalDescription() succsess in promise');

      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      sendSdp(peerConnection.localDescription); // <--- ここを加える

      // -- Vanilla ICE の場合には、まだSDPは送らない --
    }).catch(function(err) {
      console.error(err);
    });
  }

ICE candidateも、すぐに交換する

ICE candidateを収集した際も、すぐに送るように変更します。

function prepareNewConnection() {
    // ... 省略 ...

    // --- on get local ICE candidate
    peer.onicecandidate = function (evt) {
      if (evt.candidate) {
        console.log(evt.candidate);

        // Trickle ICE の場合は、ICE candidateを相手に送る
        sendIceCandidate(evt.candidate); // <--- ここを追加する

        // Vanilla ICE の場合には、何もしない
      } else {
        console.log('empty ice event');

        // Trickle ICE の場合は、何もしない
        
        // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
        //sendSdp(peer.localDescription); // <-- ここをコメントアウトする
      }
    };

    // ... 省略 ....
  }

  function sendIceCandidate(candidate) {
    console.log('---sending ICE candidate ---');
    let obj = { type: 'candidate', ice: candidate };
    let message = JSON.stringify(obj);
    console.log('sending candidate=' + message);
    ws.send(message);
  }

合わせてICE candidateをWebSocket経由で受け取った場合の処理も追加しましょう。相手からICE candidateを受け取ったら、その度にRTCPeerConnection.addIceCandidate()で覚えさせます。

ws.onmessage = function(evt) {
    console.log('ws onmessage() data:', evt.data);
    let message = JSON.parse(evt.data);
    if (message.type === 'offer') {
      // -- got offer ---
      console.log('Received offer ...');
      textToReceiveSdp.value = message.sdp;
      let offer = new RTCSessionDescription(message);
      setOffer(offer);
    }
    else if (message.type === 'answer') {
      // --- got answer ---
      console.log('Received answer ...');
      textToReceiveSdp.value = message.sdp;
      let answer = new RTCSessionDescription(message);
      setAnswer(answer);
    }
    else if (message.type === 'candidate') { // <--- ここから追加
      // --- got ICE candidate ---
      console.log('Received ICE candidate ...');
      let candidate = new RTCIceCandidate(message.ice);
      console.log(candidate);
      addIceCandidate(candidate);
    }
  };

  function addIceCandidate(candidate) {
    if (peerConnection) {
      peerConnection.addIceCandidate(candidate);
    }
    else {
      console.error('PeerConnection not exist!');
      return;
    }
  }

さあ、これで修正は完了です。

Trickle ICEを実行しよう

手順はVanilla ICEの場合と同じです。シグナリングサーバーを起動して、ChromeかFirefoxのウィンドウを2つ開いて修正したHTMLを読み込んでください。あとは同様に[Start Video]→[Connect]です。

見た目も特に変わりはありません。もしかしたら人によっては早く繋がるのを実感できるかもしれません。

gitub pages/githubも用意しています。

2台のPC間の通信

ここまできたら、せっかくなので2台の別々のPCで通信してみたくなります。同じネットワークに属するPC同士ならば通信できるはずです。例として次のような状況を考えてみましょう。

  • IPアドレスが 192.168.0.2 と、 192.168.0.3 の2台のPCがある
  • 前者(192.168.0.2)のポート:8080でWebサーバー、ポート:3001でNode.jsのシグナリングサーバーが動いている
    2pc_firefox

Firefoxの場合は、接続するURLを変更すれば問題なく動きます。やっかいなのはChromeの場合です。

  • Chromeでは、カメラやマイクにアクセスするためのgetUserMedia()が、原則としてhttp://~では許可されていない
  • http://localhost/~ は例外的な扱いで許可されている
    2pc_chrome

きちんと対処すると、次のような対策が必要です。ちょっと試すにはハードルが高いですよね。

  • 証明書を取得して https://~ でアクセスするように、Webサーバーに設定
  • 合わせて、シグナリングサーバーも wss://~ の暗号化通信を使うように設定

そこで実験的に無理やり動かすには、次のような方法があります。Webサーバーとシグナリングサーバーは同一である必要はなく、また異なるWebサーバーでも構わないことを利用しています。
2pc_chrome_force

お勧めはしませんが、どうしてもやりたい場合の参考としてどうぞ。

次回は

今回はNode.jsとWebSocketを使ったシグナリングを実現しました。残念ながら今回の仕組みでは、1対1の通信しか行うことができません。次回はこれを拡張し、複数人で同時に通信できるようにしたいと思います。

オマケ:WebRTCの仕様の差分のおさらい

オマケとして、今回のWebRTC再入門2016シリーズで取り上げているWebRTC関連仕様の変更箇所について、おさらいしおきましょう。(2016年6月現在)

getUserMeida

  • navigator.mediaDevices.getUserMedia() が新しく用意された
    • 旧APIの navigator.getUserMedia()は Firefoxでは非推奨
  • ベンダープレフィックスが取れた
  • コールバックではなくPromiseベースになった
  • Firefox, Edge で利用可能。Chromeではフラグ指定が必要

ベンダープレフィックスの除去

  • Firefoxでは、主要なオブジェクトのベンダープレフィックスが取れた。mozプレフィックス付は非推奨に
    • 新:RTCPeerConnection, RTCSessionDescription, RTCIceCandidate
    • 旧:mozRTCPeerConnection, mozRTCSessionDescription, mozRTCIceCandidate (非推奨)
  • ただしChromeでは、一部ベンダープレフィックス付のまま
    • プレフィックス有り: webkitRTCPeerConnection
    • プレフィックス無し: RTCSessionDescription, RTCIceCandidate

RTCPeerConnection

  • 主要なメソッドがPromiseベースになった
    • createOffer(), createAnswer()
    • setLocalDescription(), setRemoteDescription()
  • メディアストリーム処理の新しいイベントハンドラontrack()が追加、onaddstream()は非推奨
    • Firefoxではサポート済、Chromeでは未サポート

仕様は常に更新されていますし、ブラウザの実装状況も異なります。最新の情報もご確認ください。

]]>
https://html5experts.jp/mganeko/20013/feed/ 0
HTTPSにまつわる怪しい伝説を検証する──Google I/O 2016 セッションレポート https://html5experts.jp/takoratta/20061/ https://html5experts.jp/takoratta/20061/#comments Wed, 13 Jul 2016 02:00:08 +0000 https://html5experts.jp/?p=20061 今年はGoogle I/Oに初めて社員ではない立場で参加しました。全体の感想は Google I/O 2016まとめ(Web的視点) で公開していますが、今回はその中で、気に入ったセッションの1つである”Mythbusting HTTPS: Squashing security’s urban legends”について書いてみたいと思います。

セッションは大変良くまとまっていますので、YouTubeにあがっている動画を見れる人はそちらを見てもらえればいいのですが、時間がないという人のために、その内容をまとめました。基本的には文字起こしに近いものです。

重要だとわかっているけど、なかなか導入に踏み切れない人も多いHTTPS。これについて、最新の状況が理解できるコンテンツとしてお役に立てるならば嬉しいです。

※この記事は、Qiitaに投稿された記事を、Qiitaの許可を得て転載したものです。

TL;DR

  • HTTPSはPWAppなどWebにとって必須。
  • しかし、パフォーマンス悪化するかも!? でもお高いんでしょ? それに見合うだけのメリットあるの?などなどの疑問(=都市伝説的なものも含む)があるので、それを1つ1つ検証。
  • 結論: 確かに懸念すべきものもあるが、やはり必須。正しく、早急に導入しよう。

Mythbusting HTTPS: Squashing security’s urban legends

“Mythbusting HTTPS: Squashing security’s urban legends” はGoogle I/O 2016の初日の夕方に行われたセッションです。ChromeのセキュリティチームでSSL/TSLを担当するエンジニアであるエミリー(Emily Stark)自らが登壇しました。

Mythbustingとは

Mythとは英語で「神話」を意味します。Bustにはいくつか意味がありますが、ここではゴーストバスターズなどと同じように、「退治する」、「撃退する」の意味になります。実は、オーストラリアで制作され、米国でも放映されているMythbustersという人気テレビ番組があります。都市伝説のような伝説を検証する科学エンターテイメント番組なのですが、タイトルのMythbustingはもしかしたらそこからとったのかもしれません。

Mythbustersは日本でもケーブルテレビなどで放映されておりますが、そのタイトルは「怪しい伝説」となっています。この投稿のタイトルもそれに習いました。

セッション動画

セッション動画はここから。英語が苦手な方も、Google Developers Japan: 技術と英語を同時に、しかも無料で勉強できる画期的な方法 にあるように字幕付きならば、技術的な内容ですので、十分内容を掴めるはずです。

HTTPSのおさらい

セッションでは、スピーカーであるエミリーが2010年の世界から見ると、2016年の今日のWebは大きく進化したというところから話し始めます。ホーム画面への追加(Add to Home screen)やプッシュ通知、デバイスオリエンテーション、ジオロケーションなどプログレッシブWebアプリケーションをはじめとするWebをよりアプリに近づける技術が使われるようになっています。

一方で、そのようなアプリケーション的なWebが普及する中、未だに課金や商取引、個人情報をやりとりするようなデータを扱っていながら、第三者から盗み見されるようなネットワークで運用されているところもあったり、ISPやWiFiプロバイダーのような第三者がWebサイト運営者の意図しないようにコンテンツを書きかえることさえ起きています。

データ盗聴の危険性

意図しないコンテンツの挿入

エミリーはだからこそHTTPSが重要だと強調します。

現在のブラウザはHTTPSでアクセスしているサイトには、それがHTTPSであることをインディケーターで示します。ですが、すべてのWebサイトがHTTPSをサポートし、この緑のインディケーターを必要としない世界こそ、望むべきものです。

HTTPSには3つの役割があります。

HTTPSの役割

  • 認証(Identity): https://google.com にアクセスすると、ブラウザはgoogle.comから証明書を受信し、それにより今アクセスしているgoogle.comが真正のgoogle.comであることを確認します。
  • 守秘性(Confidentiality): 一度、真正のgoogle.comにアクセスしていることが確認されると、その後の通信はgoogle.comとブラウザ以外には盗聴されない形で行われます。
  • 完全性(Integrity): google.comとブラウザの間でのデータは第三者により改ざんされないことが保証されます。

このようなセキュリティ上の利点があるものの、HTTPSを導入しようとしたときに、コスト、パフォーマンス、そして保守の面で課題があるのではないかと心配されるかもしれません。しかし、それは過去のものです。そのようにエミリーは言います。

HTTPSに関しての誤解

HTTPSにまつわる話

これらの誤解は結局のところ、5つほどの話に整理できます。それらのうちのいくつかは過去には正しかったものの、現在では都市伝説になっているものもあります。エミリーは5つの話を現在でも事実の話と怪しい伝説とに分類していきます。

5

その5つの話は次の通りです。

  1. 私のサイトはHTTPSを必要とするほど重要ではない
  2. HTTPSを導入すると遅くなる
  3. HTTPSに関しての攻撃がたくさんあるみたい
  4. HTTPSにはお金がたくさんかかる
  5. 私のサイトはHTTPSに移行できるけど、使っている(依存している)サードパーティ(のサービス)はどうしたらいいの?

それでは、エミリーが1つ1つをどのように解説していったかをお伝えしましょう。

「私のサイトはHTTPSを必要とするほど重要ではない」

エミリーはアリスの話を始めます。

アリスというWebデベロッパーがいます。彼女の旅行ガイドのWebサイトはログインフォームもなければ、クレジットカード番号の入力フォームもない。そこでアリスは自分のサイトにはHTTPSは必要ないと考えます。ですが、ある日、彼女の友人からアリスの旅行ガイドサイトが遅いと言われて、調べてみると、友人が見ている彼女のサイトには彼女が入れたのではない様々な広告が挿入されていたことに気づくのです。それらにはマルウェアが埋め込まれていて、アリスのサイトを信じた友人はサイト内の広告もクリックし、マルウェアに感染してしまっていたのです。

アリスはまた新しい機能をジオロケーションAPIを用いて実装しようとします。ユーザーがどこにいるかを把握し、適切なトラベル情報を提供しようと試みたのです。しかし、彼女はChromeでは動作しないことに気づきます。

ジオロケーションAPI

現在、Chromeを始めとする多くのモダンブラウザはジオロケーションAPIをはじめとするパワフルな新しいAPIの利用はセキュアはHTTPS上でしか動作しないようにし始めています。

HTTPSを必要とする機能

このように、HTTPSはプライバシーやセキュリティ上の重要なデータのやりとりが発生するようなサイトだけでなく、ユーザー体験を向上させるすべてのサイトに必要なものとなっています。

つまり、この話はです。

「HTTPSを導入すると遅くなる」

次はボブの話。ボブはEコマースサイトを持っています。彼はサイトを分析し、コンバージョン最適化のためにはレイテンシーを下げることが必要と判断しましたが、HTTPSとパフォーマンスについてあまりよくない話を聞きました。

HTTPSに移行したyell.comはパフォーマンスの差(HTTPとHTTPS)は相対的に大きかったと言っています。しかしながら、2010年にGmailをHTTPSに移行したGoogleは無視できるほどの差しかなかったとしています。

ボブはこれらの話を知り、さらに自分自身でも調査をしました。

エミリーはボブや一般のWebデベロッパーのためにHTTPSにするとネットワーク層で何が起きるのかを説明し、どのように遅延を避けるかを解説します。

HTTPS移行で起きること

一般のユーザーはブックマークするなどした以前のHTTPのURLでサイトにアクセスしてきますので、まずはHTTPからHTTPSへのリダイレクトが起きます。そしてその次にTLSハンドシェイクで証明書のやりとりと双方がTLSを使えることの確認が行われます。

HSTS

まず最初のHTTPからHTTPSへのリダイレクトに関しては、HTTP Strict Transport Security(HSTS)の導入を勧めます。HTTPヘッダーにHSTSの記述をすることで、次回からのアクセス時にブラウザはHTTPでのURLを自動的に内部でHTTPSに置き換えます。これにより2度目以降はこのリダイレクトは発生しなくなります。

注意事項としては、これは一度セットされると、HTTPヘッダーが失効するまで有効ですので、HTTPSのテスト時や完全にHTTPSでのアクセスをサポートできるまでは行ってはいけません。

TLS False Start

次のTLSハンドシェイクにおける最適化は2ウェイラウンドトリップをいかに高速化するかに関わるのですが、ここではTLS False Startの利用を勧めます。

TLS False Start

TLS False Startは2ウェイラウンドトリップを待つことなく、クライアントであるブラウザがHTTPSでの通信をリクエストするものです。

TLS Session Resumption

もう1つの最適化はTLS Session Resumptionと呼ばれるもので、一度TLS通信を行った場合、以前に使ったセッションIDを用いることで、同じTLSハンドシェイクは必要ないと省略するものです。

TLS Session Resumption

HTTP/2

ここでエミリーはHTTP/2のいくつかの特徴を話します。

HTTP/2はリクエストとレスポンスを並列で行うパイプライン化により、1つのリクエストのレスポンスを待たずに次のリクエストを送れます。また、サーバープッシュにより、ブラウザがHTMLをパースし、追加のリソースであるスタイルシート(CSS)やJavaScript、イメージなどをリクエストするのを待たずに、サーバー側から必要となるリソースをプッシュすることが可能です。

HTTP/2には他にもさまざまな利点があります。

ここで大事なのは、このHTTP/2はHTTPSでのみ利用可能なことです。

エミリーはHTTP/2がHTTPSでのみ利用可能になっているのには、2つの理由があると言います。1つがHTTPSを普及させるためのインセンティブとして、つまりHTTP/2を使いたいならば、HTTPSを使わなければいけないとすることにより普及を目論んでいるということです。もう1つがプロキシサーバーなどの中継機器によりHTTP/2の通信が阻害されることを避けるためです。

ここで、さきのボブの話に戻ります。ボブが知ったyell.comのHTTPS移行の話ですが、実は彼らが遭遇したパフォーマンス上の課題は古いロードバランサーによるものであったそうです。彼らは将来的にはHTTP/2にアップグレードすることで、TLSネゴシエーションを改良し、RTTを減らすことで、HTTPSへの移行をネガティブなインパクトではなく、ポジティブなインパクトに変えたいと言っています。

実際に、別の事例としてweather.comがあります。彼らはHTTPSに移行した際にネガティブなインパクトがあったのですが、最終的にHTTP/2にアップグレードすることで、それらはほとんど解消できたそうです。

ボブはこれらの調査結果を踏まえて、解説されたような設定を行うとともに、HTTP/2にアップグレードすることで、パフォーマンスの心配なく、HTTPSに移行することができました。

結論として、この話、「HTTPSを導入すると遅くなる」は(ほとんど)嘘とエミリーは言います。「ほとんど」となっているのは、HTTP/2へのアップグレードなどを含めて総合的にはという意味でしょう。

参考情報

HTTPSに移行したyell.comの話はHTTPS is Hard – The Yell Blogに詳しく書かれています。これは大変読み応えのある記事なので、英語ですがお勧めです。

また、HSTSについては以前書いたHSTS (HTTP Strict Transport Security) の導入 – Qiitaに簡潔にまとめてあります。

HSTSを含むTLSの設定などは、IPA(情報処理推進機構)のSSL/TLS暗号設定ガイドライン~安全なウェブサイトのために(暗号設定対策編)~がお勧めです。

「HTTPSに関しての攻撃がたくさんあるみたい」

今度はイブの話。技術的な知識もあるイブはちょっとお金に困っていました。そこでHTTPSの脆弱性の話に目をつけ、あるHTTPSの脆弱性を持ったまま放置されているサイトを探します。クレジットカードやログイン情報を持っているサイトなどで脆弱性が放置されているサイトにアクセスします。

これは実際に起こりうる話です。

TLS脆弱性

HTTPSのセキュリティの研究は最近とても注目されており、単なる攻撃の可能性を理論で示すだけではなく、より実践的な形で攻撃を示したり、インターネット上で実際に脆弱なサイトを見つけるツールやスクリプトを公開したりすることも多くなっています。

実際に図に示したように、多くの脆弱性と攻撃が見つかっています。

エミリーはこう考えたらどうかと言います。

「HTTPSはライフジャケットのようなもの。いろいろと問題はあるけれど、HTTPはライフジャケット無しのようなもの」

HTTPSを使わないのではなく、ツールなどを用いて、サイトで用いる技術を常に最新の状態に保つのが良いでしょう。例えば、SSL Labsスキャニングツールを提供していて、サイトのURLを入力することで、サイトのレイティングや必要となる設定などを示してくれます。

https://qiita.comのスキャン結果

類似のツールとして、MozillaはSSL Configuration Generatorを提供しています。利用するサーバーやバージョンなどを入力することで、設定が生成されます。

Mozilla SSL Configuration Generator

さて、この話の結論は事実(ただし、心配しすぎることは無い)です。「心配しすぎることはない」のは、ライフジャケットなしのHTTPよりも、多少の問題があってもライフジャケットとなるHTTPSは使ったほうが良いし、問題もツールなどを用いて常に最新ソフトウェアを使い、構成を正しくすることで解決できるからです。

「HTTPSにはお金がたくさんかかる」

次の主人公はチャーリー。

チャーリーは2年前にスタートアップ向きの素晴らしいアイデアを思いつきました。彼はサイトを作り、そこで人々がチャットを行えるようにし、無事ベンチャーキャピタルからも資金を調達できました。それから2年経ち、資金調達で得た資金も枯渇しつつありこともあって、彼はコストには厳しくなってきています。HTTPSにはお金がかかるという話が気になっています。

14

お金はHTTPSのいろいろなところに関係しています。

パフォーマンスもお金に関係しますし、最後に紹介する広告もお金の話です。ここでは、特にHTTPSの財務上の課題について話します。HTTPSを支える暗号技術における認証は証明書が必要です。これは従来は認証局から有償で購入する必要がありました。身分証明や法人登記書類などを提示して、認証局より証明書を購入し、それをブラウザに対しての自らの認証として使います。

さて、この証明書はいくらかかるのでしょう? 確かに以前はお金がかかりましたが、今ではいくつかの安価な解決策があります。例えば、プロジェクトSSLMate(https://sslmate.com )というものが数年前から始まっていますが、ここでは1つのドメイン年額$15.95です。複数証明書や追加オプションなどを使うとしても、財務にさほど大きな負担を与えません。新しいプロジェクトのLet’s Encrypt(https://letsencrypt.org/ )は無料で証明書を発行します。

このSSLMateとLet’s Encryptは無料もしくは安価での証明書の取得を実現するだけでなく、自動化のためのコマンドラインツールも提供しているので、期限切れを防ぐことなどの管理作業を行うことができます。

このように、証明書はあまりお金がかからないものとなりました。

もう1つのお金にまつわる心配は、検索ランクへの影響です。HTTPとHTTPSという2つのバージョンのサイトがあったならば、検索エンジンが混乱してしまい、検索ランクが下がってしまうのではないでしょうか?

Googleはサイトの移動に関していくつかのガイドラインを示しています。1つはサイトを移動した場合、301リダイレクトを返すようにと勧めています。また、検索エンジンのクローラーがアクセスしてきた時のために、canonicalリンク要素を提供する必要があります。

15

他のガイドラインとしては、次のようなものがあります。

https://support.google.com/webmasters/answer/6033049 https://support.google.com/webmasters/answer/6073543 https://plus.google.com/+JohnMueller/posts/PY1xCWbeDVC

これらのガイドラインに従うことで、検索ランクへの影響は最小限に抑えることができ、その影響もしばらくしたら回復します。逆に、GoogleはHTTPSのサイトにランキングブーストを行っています。現在は小さいブーストだが、将来的には変更もあり得ることをエミリーは示唆しています。

証明書のコストも無料か低価格で抑えられ、検索結果への影響も無視できるレベルなので、この話の結論はとなります。

「私のサイトはHTTPSに移行できるけど、使っている(依存している)サードパーティ(のサービス)はどうしたらいいの?」

最後の話の登場人物はフランシスコ。彼によるサードパーティコンテンツにまつわる話です。 皆さんのサイトも多くのサードパーティに依存していることでしょう。

フランシスコは大きなニュースサイトを運営しています。このニュースサイトには多くのレガシーなコンテンツも掲載されています。フランシスコはサイトをHTTPS化すると、すべてのサードパーティコンテンツも同様にHTTPS対応していなければならないと聞いていたので、サイトのHTTPS化はかなり大変ではないかと心配しています。

これは事実で、サードパーティコンテンツもHTTPSに対応していなければなりません。

まず一番最初に心配したのが、サイトの収入源でもある広告です。広告がHTTPSに対応していれば、サイトの移行もすぐに行なえます。GoogleのAdSenseは現在では常にHTTPS上で提供されるようになっています。そのため、フランシスコが自分のサイトをHTTPSに移行する前であっても、実はAdSenseはHTTPSですでに提供されています。つまり、AdSenseについては心配の必要はありません。ただし、HTTPS通信がブロックされているいくつかの国は例外です。

これはGoogleだけの動きではありません。業界全体のトレンドです。2015年、IAB(Interactive Advertising Bureau)はブログ記事の中で約80%の広告ネットワークがすでにHTTPSに対応していると明かしています。

Adopting Encryption: The Need for HTTPS

このように最近では広告がHTTPSに対応していないから、サイトのHTTPS化ができないというのはほとんどなくなっています。もし、広告ネットワークがHTTPSに対応していないようだったら、是非確認してみてください。近い将来に対応予定であることがほとんどでしょうし、もしそうでなかったならば何故対応しないか、是非対応してほしいと言ってみましょう。

次に心配なサードパーティコンテンツはHTTPリファラーヘッダーに依存しているものです。アクセス解析のためなどのいくつかの理由によりパートナーサイトであるサードパーティはHTTPリファラーヘッダーを見て、トラフィックがフランシスコのサイトから来たものであることを確認します。問題は、HTTPSでホストされているサイト上のHTTPでホストされているサイトへのリンクをユーザーがクリックした場合、ブラウザはプライバシーの理由からリファラーヘッダーを取り去ってしまうのです。フランシスコにとって、これは困ります。

ここで解決のために必要となるのが、Referrer Policyです。a要素などに付けられるこのReffer Policyとして、”origin-when-cross-origin”を指定することで、リファラーヘッダーがHTTPサイトに対して送られることになります。他にも複数のポリシーがあるので、適切なものを選び、必要な状態でリファラーを送ることができます。

&lt;a href="http://external-partner.com/..." referrerpolicy="origin-when-cross-origin"&gt;Click here!&lt;/a&gt;

最後のフランシスコの心配は少し一般的なものです。Mixed Contentsと言われる、HTTPSサイトでホストされているページの中にHTTPサイトでホストされるセキュアではないコンテンツが含まれるケースです。このようなコンテンツがロードされることで、HTTPSにより高められるセキュリティが妥協したものになってしまうため、厳しい措置がとられています。スクリプトやiFrameのようなものはブラウザによりブロックされます。

また、フランシスコのサイトでは多くの古い記事にロードされている写真などがあったのですが、これらはHTTPでホストされています。このようなイメージに対してはブラウザはブロックはしませんので、イメージも見ることはできますが、ブラウザのアドレスバーのHTTPSアクセスを示す緑色は消えてしまいます。

CSP(Content Security Policy)を用いてこの問題は対処することができます。HTTPレスポンスヘッダーに次のように指定します。

Content-Security-Policy-Report-Only: default-src https:
  'unsafe-inline' 'unsafe-eval'; report-uri
  https://example.com/reportEndpoint

default-src https:という指定から、ブラウザはすべてのコンテンツはHTTPSでロードしようとします。ただし、'unsafe-inline' 'unsafe-eval'という指定から、動的に生成されたものやインラインスクリプトは例外とします。ブラウザがコンテンツをロード中に、もしこのポリシーに従わなかったものを見つけたら、https://example.com/reportEndpointにレポートするように指示されています。

このポリシーの例は、”Report-Only”なので、ユーザーから見た時の挙動は変わりません。セキュアでないコンテンツのロードがブロックされることもありません。ただ、もしそのようなコンテンツがロードされたことがあったならば、フランシスコはレポートを通じて知ることができます。

このレポーティングのためのインフラを用意するのも面倒であった場合、report-uri.ioというサービスを使うことも可能です。エンドポイントを用意してくれるだけでなく、解析やビジュアリゼーションまでも行ってくれます。

report-uri.io

ここで説明したことは、Chrome DevToolsのセキュリティパネルで見ることができるようになっています。

DevTools Security Panel

この話の結論は真実です。ですが、ここ数年で状況は改善されつつあり、業界全体として対応に取り組んでいるものです。

まとめ

確かに、10年や15年前はHTTPSは遅く、導入にはコストがかかり、セットアップは手間のかかるものでしたが、現在では多くの障壁は取り払われています。

このセッションでエミリーが解説したように、HTTPS関連のツールやサービスなどが充実し、導入は昔の比ではないほど敷居が下がっています。インターネットのセキュリティの強化は誰かひとりや一社の取り組みで一日にして成るものではありません。HTTPSの導入を進めることで、安全に快適なインターネット空間が広がるよう、皆で努力していきましょう。

補足

各お話に出てくる人物の名前にはそれぞれ由来があります。

  • アリスとボブは暗号技術を勉強したことがある人ならばご存知だと思いますが、暗号通信を二者で行うときの例として出てくるのが常にボブとアリス。それが由来。
  • イブはEvil(邪悪な)から。
  • チャーリーはどこから来たのか不明。
  • フランシスコはサンフランシスコからか。
]]>
https://html5experts.jp/takoratta/20061/feed/ 0
モバイルWebのUIを速くする基本テクニックがわかる──Google I/O 2016 High Performance Web UI https://html5experts.jp/furoshiki/19276/ https://html5experts.jp/furoshiki/19276/#comments Fri, 08 Jul 2016 00:00:28 +0000 https://html5experts.jp/?p=19276 こんにちは、ふろしきです!

私はHTML5 Experts.jpで、過去2年ほどGoogle I/Oの情報を発信し、Web技術の変化についてお伝えしてきました。振り返るとGoogleは、2014年にモバイルWebの提唱と技術要素の拡大を図り、2015年からは「RAIL(モバイルWebが目指すべきパフォーマンス指標)」や「Progressive Web Apps(アプリのように振る舞うWeb)」といった、モバイルとの親和性が高いWebを作り出すための”考え方”を推し進めました。今年2016年は、さらにそれを踏み込んでいったという感じがします。

今回のI/Oで取り上げるのもそのひとつ。毎度お馴染みGoogle Developer AdvocateのPaul Lewis氏による 「High performance web user interfaces」です。彼は、モバイルにおいて、時にアプリのように振る舞うことが求められる昨今のWeb、すなわち「Progressive Web Apps」について、UIで起こりがちなパフォーマンス問題と、その改善方法について紹介しています。

31

※ この講演、動画無しでは説明が難しかったり、前提知識も多かったりするので、私でかなりアレンジ・要約して紹介しています。より詳細に内容を知りたい場合は、ソースをみることをオススメします!

Webは時として、モバイルアプリのような体験が求められる

モバイルにおいて、ホームスクリーンは重要な場所だ。人々はホームスクリーンから、目的を達成するためのアプリを起動する。Webは、Add to Homescreenを使うことで、ホームスクリーンからWebサイトへアクセスすることができるようになった。

するとどうなるか。このホームスクリーンをよくみてほしい。どれがWebで、どれがネイティブアプリなのかは見分けがつかないだろう。Google Mapsなんかはネイティブにみえるけれど、他はまったく想像がつかない。しかしこれらが、Google Mapsと同様にネイティブアプリにみえるなら、Webはネイティブアプリのように振る舞うことが求められている。

スクリーンショット 2016-06-06 23.14.36

パフォーマンスモデル、インタラクションモデルの2つによって、Webはモバイルネイティブアプリのような振る舞いをえることができる。Progressive Web Appsを実現することができる。今日はこの2つのモデルのうち、パフォーマンスモデルの話をしたい。

昨年は、Paul IrishIlya Grigorikなどの私のチームのメンバーが、「RAIL」というパフォーマンスモデルについて話した。RAILとは、Responseは0.1秒、Animationは16ミリ秒、Idleは50ミリ秒、Loadは1秒で動作すべきというもの。ただ、それを聞いた人々は、たまに勘違いをする。この4つの要素は、どれも全て、最も重要なこととして語ってしまうのだ。それは間違っている。

例えば、Webサイトにおいて、タップした時に求められるのは、4つの要素のうちLoadが重要になる。Idleが重要になることはそこまでない。そして、ホームスクリーンからタップして起動されるProgressive Web Appsでは、ResponseやAnimationが重要になる。Webサイトをつくるのと、Progressive Web Appsをつくるのでは、求められることが違う。

スクリーンショット 2016-06-11 17.19.02

さて、このようにパフォーマンス面で求められることが異なるProgressive Web Apps。そこに、3つのコンポーネントがある。Side Navigation、Swipeable Cards、Expand an Collapse。これらを実現するセオリーを紹介しよう。

1. Side Navigation

スクリーンショット 2016-07-03 22.27.14

まずは、このコンポーネント。メニューボタンをタップすると左からスライドインするバー。これは、2つのElementによって構成される。半透明の黒い背景と、サイドメニューを表示する領域だ。

スクリーンショット 2016-07-03 23.18.35

このサイドメニューの部分のCSSは非表示の時、CSSにpointer-events: none;を指定する。そして、表示されたタイミングでpointer-events: auto;を指定する。

そしてここからが大事な話。左から右、あるいは右から左に移動させる際に、transformを使う。ブラウザがDOMの位置を変更する際に、CPUを使ったレイアウト変更してはいけない。GPUの力を借りて、描画位置を変更することで、最適なパフォーマンスを得ることができる。

例えば、一昔前。サイドメニューが左に消えている時にCSSは

.side-nav {
  position:       fixed;
  left:           -102%; /* DOMのレイアウト位置を左にずらしてメニューを隠す */
  top:            0;
  width:          100%;
  height:         100%;
  over-flow:      hidden;
  pointer-events: none;
}

と、left: -102%で隠す。これは一般的な方法だった。しかし、描画を高速に処理できるGPUの恩恵を受けたいなら、transformを使って以下のように記述する。

.side-nav {
  position:       fixed;
  left:           0;                 /* DOMのレイアウト位置は常に0のまま */
  top:            0;
  width:          100%;
  height:         100%;
  over-flow:      hidden;
  pointer-events: none;
  transform:      translateX(-102%); /* 描画の位置を左にずらすことでメニューを隠す */
  will-change:    none;              /* <- これは何!? */
}

サイドメニューのDOMのレイアウト位置としては、x位置のleftもy位置のtopも、0のまま。横幅widthも縦幅heightも、100%ということで、全面を覆っているという扱いになる。しかし、transform: translateX(-102%);で描画の位置自体を、左に寄せている。

そして、ここで登場するのがwill-chanage: none;だ。

一昔前にtransform: translateZ(0);をCSSプロパティに指定して、パフォーマンスを改善するというハックが出回ったのをご存知だろうか。このCSSが指定されると、描画には必然的にGPUの力が必要になるため、強制的にGPUに描画を依頼することになる。GPUの恩恵を受けるために活用されたこのバッドノウハウは、will-chanage: transform;という新しいCSSプロパティをWeb標準として追加することによって、同様のことを実現できるようにした。(※注:実態はブラウザ対応の問題もあり、今もtransform: translateZ(0);を使うのが一般的)

ただ、transform: translateZ(0);will-chanage: transform;といったCSS指定は、常時ビデオカード上のRAMメモリーに描画結果をテクスチャーとして保存することになる。モバイル環境では、バッテリー消費などに悪影響を及ぼすことになる。動作するタイミングだけwill-chanage: transform;を指定し、動作しない時は無効化will-chanage: none;するといい。これが、バッテリー消費パフォーマンスと描画速度パフォーマンスのトレードオフ問題に対する、落とし所だ。

スクリーンショット 2016-07-04 0.37.11

黒背景については、will-change: opacity;というプロパティがあり、transformと同様の方法で、高いパフォーマンスで描画させることができる。(※ JSの実装については、「2. Swipeable Cards」にノウハウが似ているので割愛)

2. Swipeable Cards

スクリーンショット 2016-07-03 22.27.37

CSSを使ったパフォーマンス改善のテクニックの他に、注意しなくてはいけないのが、スワイプ操作時のコンポーネントの移動処理。ユーザーからの指の位置状況を入力し、それをスクリーン上に反映しなくてはいけない。この際、有用なのが「ゲームループ」のノウハウだ。

描画のイベントは常に、1/60秒ごとに発生する。対してスワイプのイベントは、常に一定には発生しない。描画のタイミングにはあわせてくれないのだ。

スクリーンショット 2016-07-04 1.05.35

そこで、スワイプにより発生するイベントについては、変数に位置情報だけを記録する。そして、描画時のイベントでは、記録された位置情報を元に、CSSを通じて描画位置変更をおこなう。

スワイプの開始時・移動時・終了時は以下の通り。this.startXthis.currentXthis.targetXといった変数に、現在の位置や、移動すべき位置を記録している。

/**
 * スワイプ開始
 */
onStart(evt) {

  // スワイプの開始位置を記録する
  this.startX = evt.pageX || evt.touches[0].pageX;
  this.currentX = this.startX;

  // cardの移動が開始されたことを記録する
  this.draggingCard = true;

  // will-change: transform; を有効にする
  this.target.style.willChange= ‘transform’;

  // カード上の要素にイベントを伝播させないように
  evt.preventDefault();

  // アニメーションを開始する
  requestAnimationFrame(this.update);
}

/**
 * スワイプ移動時
 */
onMove(evt) {

  // スワイプの現在地点を記録する
  this.currentX = evt.pageX || evt.touches[0].pageX;

}

/**
 * スワイプ終了時
 */
onEnd(evt) {

  // cardを削除すべきかどうか判定する
  let translateX = this.currentX - this.startX;
  const threshold = this.cardWidth * 0.35;
  if( Math.abs(translateX) > threshold ) {

    // cardの移動先をスクリーンの外へ(※cardは削除)
    this.targetX = (translateX > 0) ? this.cardWidth : -this.cardWidth;

  } else {

    // cardの移動先を最初の位置へ(※cardは削除されない)
    this.targetX = 0;

  }

  // cardの移動が終了されたことを記録する
  this.draggingCard = false;
}

描画のタイミングにrequestAnimationFrameから呼び出されるコールバックで、先ほどの位置情報を元に反映していく。

/**
 * 描画内容の変更
 */
update(evt) {

  // 次の描画タイミングでも自身を呼び出す
  requestAnimationFrame(this.update);

  // スワイプ中の場合
  if( this.draggingCard ) {

    // 現在の位置を描画させる
    this.translateX = this.currentX - this.startX;

  // スワイプが完了している場合
  } else {

    // カードを削除するかしないかに応じて指定の場所に能動的に移動する
    this.translateX += (this.targetX-this.translateX)/4;

  }

  // CSSプロパティを経由してGPUに変更を伝える
  this.target.style.transform = `translateX(${this.translateX}px)`;
}

(※ この後の処理については、「3. Expand and Collapse」にノウハウが似ているので割愛。)

3. Expand and Collapse

スクリーンショット 2016-07-03 22.27.51

タップすると、領域が広がり全体化されるUIコンポーネント。CSSではどうするのか?もちろん、ここまで説明してきた「transform」を活用する!では、JSについてはどうか?実は、「2. Swipeable Cards」とは異なり、スワイプ操作でなくタップによって、自動的にアニメーションする。この点で、より効率的な実装が求められる。

まず、アニメーションについて、動作中の状態はJS上で持たない。動作前後の状態だけを、CSSプロパティを通じてGPUに指示する。

スクリーンショット 2016-07-04 1.54.47

// 変化量を計算する
invert.x = first.left - last.left;
invert.y = first.top - last.top;
invert.sx = first.width / last.width;
invert.sy = first.height / last.height;

// 変化後の状態をCSSプロパティを通じてGPUに指示
card.style.transformOrigin = ‘0 0’;
card.style.transform =
    `translate(${invert.x}px, ${invert.y}px)
      scale(${invert.sx}, ${invert.sy})`;

そのままでは、タップした要素は一瞬にして全体化されてしまう。どのようにして何ミリ秒もかけて徐々に広げていくか?その方法は、CSSで指定する。JSではない。原理的には、従来よく使われているCSSアニメーションだ。

.cards {
  transition: transform 0.2s cubic-bezier(0,0,0.3.1); // アニメーションさせる
}

ここまで、Progressive Web Applsのパフォーマンス改善の話をしてきたが、「Google DevelopersのRendering peformance」が役に参考になる。一読するといいだろう。

スクリーンショット 2016-07-04 2.13.00

Progressive Web Appsのパフォーマンス改善。要はこう言いたかった

いかがでしたでしょうか?文字数の制限やコンテキストの高さもあり、多くのエンジニアに伝わるようかなりアレンジしてみましたが、ご理解いただけましたでしょうか?

Paul Lewis氏が言いたかったことは単純な話です。先ほどのGoogle Developersの記事にもありますが、Progressive Web AppsにおけるAnimationやReactionの課題は、いかにしてブラウザのレンダリング処理における「レイアウト」を減らすか、という話です。この講演は、そのTIPS集といえます。

スクリーンショット 2016-07-04 2.19.41

今日のノウハウ、特に新しいというわけでもなく2年前には既に実践されていたことです。実際のところ多くの現場では、OnsenUIやIonicのようなUIライブラリを活用することになり、このあたりの話を意識することはないのでしょう。ただ、Webのサービスを作っているフロントエンドエンジニアにとっては、ライブラリの有無に関係なく知っておくべき知識のように思えます。サイドメニューについては、Webサイトであっても鉄板のUIコンポーネントなので、Progressive Web Appsか否かはもはや関係ないノウハウだったに違いありません。

Webがモバイルに順応していくことは、今後もさらに求められていきます。これは、フレームワークやライブラリに限った話ではなく、トータルにみたWeb、フロントエンドへの要求に変化を与えるに違いません。

今後も、モバイルとWebの関わりに、目が離せませんね。

]]>
https://html5experts.jp/furoshiki/19276/feed/ 0
Webブラウザで高速な演算を可能にする低水準言語asm.jsと、WebAssembly詳解ーJavaScript が動く仕組み https://html5experts.jp/chikoski/18964/ https://html5experts.jp/chikoski/18964/#comments Thu, 07 Jul 2016 01:35:40 +0000 https://html5experts.jp/?p=18964 連載: 低水準言語asm.jsとWebAssembly詳解 (1)

Webブラウザの上で動作するアプリを書くための言語、といえば何が想起されるでしょうか。Flash、Sliverlight、Java、さまざまな言語が利用されてきましたが、やはり今のメインストリームはJavaScriptでしょう。

JavaScriptはさまざまな言語の特徴を併せ持つ動的言語で、Web技術の発展とAPIの整備の結果、Virtual Reality(VR)や画像認識、DAW(Desktop Audio Workstation)といった、少し前まではネイティブでの実装しかありえなかった種類のアプリケーションもWebブラウザをランタイムとするJavaScripで実装されるようになってきました。

そのようなアプリの代表例がゲームでしょう。少し前までのブラウザゲームといえば、リロードを繰り返すタイプのゲームか、Flashゲーム、パズルなどの簡単なものが大半を占めていたように思います。Canvasを利用して実装されたスーパマリオやNESエミュレータなどもありましたが、いずれも実験的なものであり、また20年以上前のハードウェアで快適に動くゲームだったことを考えると、CPU時間を大量に消費する「重厚」なものではありませんでした。

しかし最近は重厚なゲームの開発も行われ始めています。この嚆矢はBananaBreadでしょう。これはMozillaのエンジニアチームが開発した複数同時対戦可能なFPS(First Person Shooting)です。このようなゲームの実現が可能になったのは、WebGL、Web Workers、Web Audio API、Gamepad API、IndexedDBなどに代表されるAPIの充実もありますが、既存のJavaScriptエンジンではなしえなかった高速の演算を可能にする、低水準言語の整備のおかげでもあります。

この連載は4回にわたって、Webブラウザ上で動作する低水準言語であるasm.jsと、(いまのところ)そのバイナリフォーマットであるWebAssemblyについて、その設計と仕様、そして開発環境を紹介します。

低水準言語asm.js

asm.jsはMozillaが研究開発したJavaScriptのサブセットで、2013年に発表されました。現在はFirefoxとGoogle Chromeによって実装が行われ、EdgeやSafariも対応を表明しています。その特徴はなんといっても動作の高速さです。次のグラフはasm.js発表時に公開されたベンチマークの結果で、棒グラフが短ければ短いほど、処理が高速であることを意味しています。このグラフによると、ベンチマークの種類にも依存しますが、概ねCやC++によるネイティブ実装の半分程度のスピードで動作していることがわかります。

asm.js のベンチマーク結果。ネイティブの半分程度のスピードで動作している。 https://kripken.github.io/mloc_emscripten_talk/cppcon.html#/24より引用

このような高速に動作を可能にしているのは、事前コンパイルと呼ばれる技術です。AOT(Ahead of Time)とも呼ばれるこの技術を利用すると、プログラムはその実行直前にコンパイルが行われ、ネイティブコードへと変換されます。ブラウザは内蔵するコンパイラでasm.jsで書かれたコードをネイティブコードに変換し、ネイティブコードを実行することで、この高速性能を実現しているのです。

事前コンパイルを可能とするために、asm.jsで書かれたプログラムは以下にあげる特徴を持っています。

  • 変数や式、関数の型が静的解析可能である
  • 数値計算に特化している
  • 作成や属性の参照、メソッド呼出といったオブジェクトに関する操作ができない
  • 利用できるコレクション型はTyped Arrayのみである

一般のJavaScriptやAngular、RxJSといったモダンなフレームワークの提供するDSL(Domain Specific Language)に 慣れた身からすれば、機能が制限され、「低水準な」印象が拭えません。2013年にもなって、なぜ、このような制約の強い言語が開発されたのでしょうか。もちろんJavaScript発展の文脈に基づく、実用上の要求があるためです。それを理解するに、まずJavaScriptの動作について簡単に(かつ大雑把に)振り返ることとしましょう。

JavaScriptが動く仕組み

プログラミング言語は「コンパイラ型」と「インタプリタ型」の2つに分けることができます。C言語やC++は前者の典型で、 後者の典型はPerlやRuby、Pythonでしょう。JavaScriptも後者に分けられます。インタプリタ型の特徴は、あるプログラムの動作に「インタプリタ」と呼ばれる別のプログラムが必要である点です。SpiderMonkey(Firefox)、Chakra(Edge)、V8(Chrome / Node.js)はJavaScript向けのインタプリタとして有名でしょう。

インタプリタは、文字列を解釈してプログラムとしての文法的構造を取得します。この過程を字句解析・構文解析と呼び、 結果得られた文法的な構造のことを抽象構文木(AST: Abstract Sytactic Tree)と呼びます。例えばa=1+2*3; のASTは次のようになります。

a = 1 + 2 * 3;の抽象構文木

素朴なインタプリタは、この抽象構文木を枝の方から実行していきます。先ほどの例だと、まず2 * 3の計算を行い、 その結果である6で2 * 3に相当する部分木を置き換えます。その後1 + 6を計算し、最後にa = 6を計算します。この場合では、*や+、=といった演算子や標準ライブラリ中の関数などは、インタプリタ中の関数として実装され、それらをASTを解釈する巨大なswitch文の中から呼び出してプログラムは実行されます。そしてこの巨大なswitch文は、プログラムの評価が終わるまで繰り返し実行されます。

仮想マシンとバイトコード

このようなインタプリタはシンプルで理解しやすいのですが、変数のスコープや返り値の受け渡しの実現が困難になりがちです。そこで多くのインタプリタはASTをバイトコードに変換して実行します。バイトコードは単なるASTのバイナリ表現ではなく、インタプリタによって実装された仮想的なハードウェア(仮想マシン)を動かすマシンコード、つまり仮想的なネイティブコードとなっています。例えば(a,b,c)=>a+b*cという関数は、SpiderMonkeyによって次のようなバイトコードの列に変換されます。

getarg 0
getarg 1
getarg 2
mul
add
return
retrval

getargやmul、addなどはSpiderMonkeyの実装している仮想マシンの持つ命令です。仮想マシンには計算に使う値をスタックに保存するスタックマシンと、レジスタに配置するレジスタマシンとがありますが、SpiderMonkeyはスタックマシンを採用しており、変数への代入や、実引数の参照はスタックに対する操作として実現されています。

なおSpiderMonkeyの提供するバイトコードはこちらのサイトで一覧できます。

バイトコードに変わったとはいえ、実行のモデルは変わりません。バイトコードを1つずつとってきては、バイトコードを解釈する巨大なswitch文を通じて、各命令を実装する関数が呼ばれます。これがプログラムの実行が終了するまで繰り返されます。

型情報の不足

どうせネイティブコードを出力するなら、仮想マシンのネイティブコードではなく、実マシンのネイティブコードを出力すればいいのに。そう思われるのも当然ですが、出力しない、もしくはできないのにも理由があります。それらの中で大きなものの1つが、型情報の不足です。

function wrap(value){
  return {value: value};
}

var b1 = wrap(1);
var b2 = wrap(2);
var result = b1.value + b2.value;

resultにはどういう種類のデータが入るでしょうか?数値とすぐわかる方も多いとは思います。ではなぜ数値だとわかったのでしょうか?頭の中で上記のプログラムを実行した結果、b1.valueとb2.valueの値が両方とも数値であることがわかり、 数値同士の加算は数値になることから、resultには数値が代入されると結論づけたのではないでしょうか。

では、このwrapの返り値のvalue属性には常に数値が代入されているでしょうか?wrapの引数は数値でなければならない、とはどこにも書いてありません。そのため、次のような呼び出しも可能です。

var b3 = wrap("abcd");
var b4 = wrap({id: 1234});
var b5 = wrap(null);
var b6 = wrap(undefined);

b3,b4,5,b6それぞれのvalue属性の型も、string,object,null,undefinedと異なる型になっています。 このようにJavaScriptのプログラムは実行するまで変数や演算結果の型がわからないことが多々あります。 これはプログラムの中に型の情報が含まれていないためです。 もし型情報が含まれていれば、実行しなくても変数や演算の結果の型を決められます。 型付けの強い言語の代表例であるRustを使って同様のプログラムを記述すると、次のようになります:

struct Box<T>{value: T}
fn wrap<T>(value:T) -> Box<T>{
  Box{value: value}
}
fn main() {
  let b1 = wrap(1);
  let b2 = wrap(2);
  let result = b1.value + b2.value;
  println!("result = {}", result);
}

このプログラムでは実行しなくても、resultの型はintであることがわかります。resultの宣言からは、型宣言を省略してあります。それでもRustの処理系は他の情報から型を決定して型を決めています。それはBoxとwrapの宣言についている型情報、そしてwrapを呼び出した際の引数から、b1.valueとb2.valueの型が決定できるためです。

さて型がわからないことが、ネイティブコードの出力にどのような影響を与えるのでしょうか。それは端的にいえば、出力するネイティブコードが冗長になるということです。例えばIntelのCPUの場合、加算だけでも20種類以上の命令があります。 これはデータ型と、データの保存場所によって使用する命令が異なるからです。データ型が適切に決定できているなら、使用する命令を1つに絞ることができます。

しかしデータ型を適切に決定できない場合、その可能性を1つずつチェックし、そのチェックした結果に合わせて使用する命令を決めるといったようなコードを出力せざるをえません。その結果コード全体は冗長になり、スピードもあまりでなくなってしまいます。ネイティブコードの出力にも時間がかかるため、その時間に見合った効果が得にくくなってしまいます。

JITコンパイル

ネイティブコードを出力したいが、ソースコードには型に関する情報がない。この状況を打破するために利用されている技術がJIT(Just in Time)コンパイルです。これはJavaScriptをいきなり高速に動かすためのネイティブコードに変換せず、しばらくインタプリタなどで動作させます。変数に代入される値を観察して、その型に関する統計情報を集めます。

この統計情報と、1つの変数には同じ種類のデータが代入される傾向にある、というヒューリスティックを利用してその変数の型を推定していきます。推定がある程度できた時点で、該当するコードからネイティブコードを出力します。このようにプログラムを動かしながら、必要に応じてネイティブコードへと出力するのがJITです。

SpiderMonkeyではJITは2段階に分かれています。まずはnullチェックなどを含んだ冗長なコードを出力するベースラインJIT を行います。その状態でしばらく動作させ、型情報の統計を取得します。ある程度の型情報が集まったところで、その情報を元により効率の良いコンパイルを行います。

図中のIon compileがそれです。

SpiderMonkeyにおけるJITコンパイルの流れ。 asm.js AOT compilation and startup performanceより引用

型情報が集まったかどうかは実行回数によって決まっているようです。コードをざっと眺めた限り、10回程度繰り返し実行されるかどうかが、コンパイルを行うかどうかの判断の目安になっているようです。

上図のように、コンパイルされたコードは常に維持されるわけではありません。ときにはbail、つまりコンパイル結果を捨てて、もう一度型の推定からやり直します。これはJavaScriptの関数呼び出しに型による制約がかけられず、推定がヒューリスティックによるもののため、仕方がないことです。例えば、次のような呼び出しが行われた場合twiceのコンパイル結果は捨てられてしまいます。

function twice(a){
  return a + a;
}
var array = [0, 1, 2, 3, ... , 100000].map(twice);
var str = twice("こんにちは");

map関数からの呼び出しによってtwiceは10000回実行されます。この途中で(正確には、行われるかどうかは処理系に依存するのですが)、twiceはnumberを引数にとり、numberを返す関数としてJITコンパイルされます。しかし次の行の引数に文字列が与えられた呼び出しによって、その結果は捨てられてしまいます。捨てないとこの処理が行えないためです。

TypeScriptのような型制約があればこのようなことが起きないのですが、残念ながらJavaScriptにはそれがありません。 そのため時には時間をかけて行ったコンパイル結果を捨て、低速に動くことを余儀なくされてしまいます。

まとめ

JITを利用することで、よく利用されるコードを高速に動作させられるようになりました。 それでも次のような問題が残っています。

  1. よく使うコードしかネイティブコードにならない
  2. 高速に動作するようになるまでにはリードタイムが必要
  3. 型の推定には失敗することがある

重厚なゲームのようなアプリケーションの場合、2や3の問題は致命的なものとなりえます。

FPS(First Person Shooting)で対戦している場合を想像してみてください。対戦の最初はコンパイルがすんでいないため、ゲームはもっさりと動作しています。これではゲームになりません。しばらくは自分の陣地でゆっくりしてコンパイルが終わるのを待ちましょう。コンパイルが終わってFPS(Frame Per Second)が出てきました。ようやく本当のゲーム開始です。

そこで相手の攻撃を読み、侵攻して、草むらに潜み、相手がくるの待ち受けます。いざ相手へ攻撃をかけようとした瞬間、3に起因するコードの再コンパイルが発生し、画面がプチフリーズ。ゲームに復帰したら、目の前には銃を構える敵が…

こういう状況を避けるためにも、上述したような問題に対する回避策が求められました。それがAOTとasm.jsでした。AOTによって1,2の問題を回避し、その実現と3の回避のために型情報のふくまれたasm.jsが導入されることとなりました。

次回はasm.jsがどのように型情報を与えていくのか、型アノテーションを中心に解説します。

]]>
https://html5experts.jp/chikoski/18964/feed/ 0
Google Assistant、Android N、Daydream、Firebase…Google I/O 基調講演で発表された最新機能を一挙紹介! https://html5experts.jp/sakkuru/19832/ https://html5experts.jp/sakkuru/19832/#comments Wed, 06 Jul 2016 02:04:12 +0000 https://html5experts.jp/?p=19832 1706738f-6ac5-6eb3-6ac1-58f6139496ea

2016年5月18~20日の3日間、Googleの本社ビルのすぐそばにあるショアライン・アンフィシアターでGoogle I/O 2016が開催されました。

例年5千名程の参加者から大幅に増えて、今年は実に約7千人の人々が参加していたようです。屋外イベント施設ということもあり、まるで野外フェスのような雰囲気でした。

ca24a108-32c6-a0a8-8ada-9b041e73fb51

本レポートではGoogle I/O最初のセッションである、基調講演の内容について紹介します。

Google Assistant

4e8f5657-c3d5-2a6f-e88e-4697c0419a44

まず最初にスンダー・ピチャイ氏に紹介されたのが、Google Assistantです。

Google Assistantは新しい対話型のボイスアシスタント機能です。Google製のアシスタント機能といえばGoogle Nowがありますが、これを進化させ、対話型のシステムとしたのがGoogle Assistantのようです。コンテキストを認識し、ユーザーが質問すると現在地や直近のクエリーなどに応じて、適切に答えを返します。

IMG_6458

例えば、有名な建築物の前で「これを設計したのは誰?」と質問すると、その場所の情報に応じて回答してくれます。

デモでは、ユーザーが「今夜やってる映画は?」と聞くと、Google Assistantは近くの映画館で上映しているタイトルをいくつか提示し、さらにユーザーが「子どもと一緒に行きたい」と言うと、子どもと一緒に鑑賞するのにふさわしいと思われる映画を提案していました。さらに「4枚チケットが必要ですか?」とGoogle Assistantが尋ねてくるので、ユーザが映画のタイトルや枚数を答えると、自動で予約し、チケットの情報を提示する、というデモが行われました。

Screen Shot 2016-06-28 at 19.07.57

このように、『提案をしてくれる』というのがGoogle Assistantの大きな特徴です。Google Assistantは基調講演の中で発表されたGoogle HomeやGoogle Alloなどの製品にも組み込まれるようです。

Google Home

2e4a891d-0d45-9b7c-447a-7cff49acf55b

続いてGoogle Assistantの機能を組み込んだGoogle Homeという製品が紹介されました。

Google Assistantのボイスアシスタント機能の他にWifiスピーカーとしての機能もあり、クラウドから音楽のストリーミング再生もできるようです。プレイリストやアルバムへのアクセスもボイスコントロールでき、Google Castを通して、AndroidやiOSのデバイスから音楽を送信することもできるとのことでした。

Amazon Echoと似たような製品のようです。大きさは手のひらに乗るくらいの小さなデバイスでした。今年の後半から使えるようになるとのことです。

Google Allo

Screen Shot 2016-06-28 at 18.44.37

次にAlloというモバイル用のメッセージングアプリが紹介されました。見た目はLineやFacebook messenger等と大差なく見えます。

c6e3abf4-b7ae-efbb-7e80-867811a417c5

文字や絵文字の大きさを自由に変えることができることが紹介されました。地味だが良い機能、ということで会場が少し盛り上がります。

47f7f868-86d2-5fa4-fb9f-1593fa2db0dd

しかし当然のことながら、ただのメッセージングアプリではありません。このAlloにはGoogle Assistantの機能が組み込まれており、なんとチャットのテキストや画像を解析して、返信用のテキストを複数提案してくれます。

例えば「Dinner later?」とチャットの相手が送ると、「I’m in!」「I’m busy」という返信用のメッセージが表示されます。ユーザは選んでクリックするだけでメッセージを送信することができます。

またチャット内の画像も解析されるので、誰かが犬の写真をアップロードすると、犬種も識別された上で、「Cude dog!」「Aww!」「Nice bernese mountain dog」といった返信メッセージが提示されます。

またGoogle Asisstantのbotががチャットの中に存在するようなかたちになっており、店のお店のサジェストや予約、検索等も対話形式でチャットの中で行うことができるようです。

さらにChromeのようにIncognito Modeが搭載されており、エンドツーエンドでの暗号化やNotificationの制御、メッセージの期限などを設定できるようになっているとのことです。

Google Duo

3651367c-53da-41c7-08ff-33b848f3657e

続いてDuoというビデオチャットアプリの紹介です。

ビデオチャットを実現するアプリは数多く世に出ていますが、このDuoにはビデオコールをかけられた側は、応答する前にそのビデオストリームを見ることができるという特徴があるようです。この機能は『Knock Knock』と呼ばれているそうで、これにより着呼側は誰が、どんな状況でコールしてきたのかを知ることができます。

コール先は電話番号に紐づくとのことで、AppleのFaceTimeの競合として考えられそうです。

9cf474f9-02e2-7500-3c8d-b5b976175073

DuoはWebRTCとQUICを使用したWebプロトコルベースのアプリと紹介されました。

AlloとDuoはAndroidとiOSで2016年夏頃リリースされる予定で、Android版では事前登録が開始されています。

Android N

続いてAndroid Nについての発表が行われました。

GoogleがAndroidの開発を行うようになってから、10年が経過したようです。今やAndroidはスマートフォンだけではなく、Android Wear、Android TV、Android Autoなど、多くのプラットフォームが登場しています。

a34e8828-9bcd-5887-8f9f-649ee8b38bc2

Androidの最新バージョンであるAndroid Nは現在プレビュー版(6/28現在Developer Preview 4)が公開中です。こちらで名前の募集も行われていましたが、現在は終了し近日中に公開されるようです。

Android Nでは、新しいOSのアップデートがあると、自動的にソフトウェアをバッググラウンドでダウンロードし、次回Androidの電源を入れた際にシームレスで新しいソフトウェアイメージに切り替わるようです。

またマルチウィンドウが正式に採用され、スマートフォンでYoutubeを見ながら他のタスクを行ったり、Android TVでは映像を見ながら検索などの他の作業を行うことができるようになります。

現在はAndroid NのDeveloper Preview 4がリリースされており、Nexus 6やNexus 5X等の一部の対応した機種であれば、Android Nを試用することができます。(Android Developers Blog)

Daydream

続いてI/O開催前から噂が絶えなかったVRに関する発表です。 a7ac330c-c1bc-6d81-4051-6125b966045d

Androidスマートフォンで高品質なVR体験を提供可能であるDaydreamというプラットフォームが発表されました。

どのようにAndroidスマートフォン上で、高品質なVR体験を可能にするのか、講演では3つの要素が挙げられていました。 まずはスマートフォン本体、次にヘッドセットコントローラのリファレンスデザイン、そしてアプリで、ぞれぞれが協調してエンドツーエンドのユーザ体験を提供するために設計されています。

ハイパフォーマンスセンサーやヘッドトラッキング、表示のレスポンスの速さなど、VR用に作られた仕様を満たしたスマートフォンは、Daydream-readyと呼ばれ、高いクオリティのVR体験を提供することができます。Daydream-readyなスマートフォンは、SamsungやAlcatel、Asusなどの企業から、2016年秋以降リリースされるようです。発表された中に日本の企業はありませんでした。

334aaad1-e834-a534-12b4-63c89e6ab9f9

リファレンスデザインとして紹介されたヘッドセットとコントローラは非常にシンプルなものでした。コントローラはボタンは少なくクリッカブルなタッチパッドがついており、スクロールやスワイプができます。オリエンテーションセンサーも内蔵しており、ユーザがどこをポインティングしているかが分かります。

Screen Shot 2016-06-28 at 18.49.36

アプリに関しては、VR用のGoogle Playが開発されており、ユーザーがアプリを探し、購入、インストールがVR上で可能になることが発表されました。またGoogle Play MoviesやGoogle Mapsのストリートビュー、Google Photos、YouTubeもVR上で鑑賞できるようになります。

Daydreamが使えるようになるのは2016年秋になるとのことですが、Android NのDeveloper Previewを使用することでアプリなどの開発は今から行うことができます。

Android Wear 2.0

18168a75-4f59-d2b0-b6fc-9f86caf03b6d

Android Wearは2年前のGoogle I/Oで初めて発表されましたが、今回は初めて大幅なアップデートとなるAndroid Wear 2.0が発表されました。

大きな変化としては、今までAndroid Wearのアプリがインターネットにアクセスするためには母艦となるAndroidやiOSスマートフォンが必要であったことに対し、Android Wear 2.0ではスタンドアロンで動作することが可能になることが挙げられます。アプリが直接BluetoothやWi-Fi、LTEなどを通してインターネットにアクセスできるようになり、ペアリングされたスマートフォンが電源を切っている場合でも、アプリは引き続きフル機能を提供できるようになります。

その他にもUIの刷新や、手書き入力の対応等も発表されました。

Android Wear 2.0はプレビュー版が公開されおり、秋には正式リリースとなるようです。

Progressive Web AppsとAccelerated Mobile Pages

基調講演も終盤に差しかかり、ここで初めて、わずかですがWebについての発表がありました。 モバイルデバイス上でWebをより快適にするための取り組みとして、Progressive Web AppsとAccelerated Mobile Pagesの2つの紹介です。

Screen Shot 2016-06-28 at 19.01.42

Progressive Web Apps、略してPWAppsは、オフライン状態でも動作可能、エンゲージメントを高めるための通知、ホームスクリーンにアイコンを登録、といったネイティブアプリのような振る舞いを実現したWebアプリのことを指します。ChromeにはServiceWorkerをはじめとする、Progressive Web Appsを実現するための仕組みが実装されています。

Screen Shot 2016-06-28 at 19.01.55

Accelerated Mobile Pagesは、既存のWeb標準に基づいた非常に速いモバイルWebサイトを作るための、オープンソースのプロジェクトです。

特に新規発表はなく、基調講演内ではわずか1分程度の紹介でしたが、Google I/O全体では多くのPWAppsやAMPのセッションが行われていました。HTML5 Experts.jpでもGoogleが新たに提唱するProgressive Web Appsの新たな開発パターン「PRPL」とは?という記事を公開していますので、そちらをご覧ください。

Android Studio

Android Studio 2.2 Previewが公開されたと発表がありました。

Screen Shot 2016-06-28 at 19.03.28

Android Studio 2.2では、ビルドが10倍、エミュレータも3倍速くなっているそうです。また、新しいレイアウトデザイナやAPKアナライザーが搭載されるとのことでした。

Firebase

続いてFirebaseの新バージョンが発表されました。

1f7e99f1-eb19-5576-3d64-48edebab815b

FirebaseはGoogleが2014年の10月に買収したBaaSサービスです。モバイルアプリに特化した分析ツールである、Firebase Analyticsが発表されました。ユーザがアプリ内で何をしているか、ユーザがどこからきたのかなど分析することが可能です。Android AnalyticsはAndroidでもiOSも扱うことができ、無制限に無料で使用することができると発表されました。

Android Instant Apps

adcac44d-79e3-0921-0316-3e9bf50fc99e

今回の基調講演の後半で一際注目を集めたのがこのAndroid Instant Appsではないでしょうか。

Webアプリの場合、ユーザはリンクをクリックするだけでアプリを使用することができますが、Androidアプリはインストールを必要とするため、ユーザがそこで離脱してしまうことが多くあります。その課題を解決する一手となるのが今回発表されたAndroid Instant Appsのようです。

今回発表されたAndroid Instant Appsでは、AndroidアプリがWeb等のリンクからインストールレスで使えるようなるとのことです。

講演中のデモの中では、いくつかのAndroidアプリをURLから起動し、実際にアプリを使用できることが示されていました。 アプリの中の直近で必要になるモジュールだけをフェッチしているらしく、起動も素早く行われ、アプリを使用してからのインストールも非常にスムーズに行えるようです。

これにより、Androidアプリ開発者は、より多くのユーザにリーチできるようになります。AndroidアプリとWebとの親和性が高くなり、Androidアプリのユーザ体験が大きく変わる機能となるのではないでしょうか。

おわりに

今年はGoogleのAIの技術力を発揮した製品やサービスが多く発表されました。

基調講演内でWebやChromeに関する発表は非常に少なかったのですが、Google I/O全体では33ものモバイルWebに関するセッションが行われていました。

既に公開されている

以外にもセッションレポートを公開していく予定です。お楽しみに。

]]>
https://html5experts.jp/sakkuru/19832/feed/ 0
Firefox 47・Safari 10、EdgeとProgressive Web Appsの新機能など─2016年6月のブラウザ関連ニュース https://html5experts.jp/myakura/19921/ https://html5experts.jp/myakura/19921/#comments Tue, 05 Jul 2016 02:25:46 +0000 https://html5experts.jp/?p=19921 連載: WEB標準化動向 (14)

6月7日にリリースされたFirefox 47や、WWDCで発表されたSafari 10、WindowsにおけるProgressive Web Apps対応など、6月も注目のブラウザニュースをお届けします。

Firefox 47リリース

6月7日にFirefox 47がリリースされました。

今回は開発者ツールを取り上げましょう。まずはResponsive Design ModeでUA文字列をカスタマイズできるようになりました。いまのところプリセットがないので面倒ではありますが、そこそこ便利です。

スクリーンショット:開発者ツールのResponsive Design Modeのツールバー。Custom User AgentでUA文字列を設定できるようになった。

さて、これはいいなと思ったのが、コンソールの複数行入力です。以前からShift+Enterで可能でしたが、Shiftを押し忘れてしまって赤い SyntaxError を見るなんてひとは多かったのではないでしょうか。

スクリーンショット:Chrome DevToolsのコンソールで、複数行入力をしようとして失敗した例。「{」でEnterを間違えて押してしまいエラーがでた。

Firefox 47の開発者ツールは入力中のコードの構文を解釈するようになり、{ など「開いている」状態でEnterを押すと自動的に複数行入力になります。foo(...) など「閉じている」場合はふつうに評価されます。

スクリーンショット:Firefoxの開発者ツールのコンソールでは、構文を解釈するので「{」は開いた状態と認識され、ただのEnterでも次の行にカーソルが移動する。

構文エラーを意図的に入力したい方には不便かもしれませんが、基本的にはとても便利です。
ちなみにSafariではたしかSafari 7くらいからできます。

ほか、Service Worker関連のデバッグに便利な機能が多数追加されています。

Safari 10発表

6月13日から17日までAppleのWWDCが開催され、MacのOSがOS XからmacOSになるなどいろんなことが発表されました。
SafariもSafari 10になることが発表されました。すでに実装される機能が公開されています。

ES6のサポートはうれしいですね。Indexed DBやCSS Filtersの接頭辞削除など、互換性も向上しています。
フォント関連機能にも大きな進展があります。Font Loading APi、WOFF 2.0、unicode-range のサポートが追加されました。とくに日本語のWebフォントは容量が大きいので、Webフォント関連の機能向上はうれしいですね。

このエントリでは、先日Webフォントの読み込み中の挙動が変更されたことも書いてあります。これまではWebフォントが読み込まれるまでテキストが表示される問題でしたが、今回の変更でFirefoxやChromeと同じく、Webフォントの読み込みに時間がかかる場合は3秒後にフォールバックのフォントで一旦表示するようになりました。Safari 10の紹介には書いてありませんが、この変更が取り込まれてることを祈りましょう。

Keynoteで発表されたApple Pay on the WebについてはSafariでのみしか利用できない、Web Paymentsワーキンググループで策定している標準APIではないといった批判の声もあります。これについてはAppleのEdward O’ConnorがWeb Paymentsワーキンググループに対して説明をしています。

マーチャントの検証機能や canMakePayments() といった標準にはないメソッドがApple Payにはあるそうです。AppleもWeb Paymentsワーキンググループに参加していますし、標準APIもよいものにしていってほしいですね。

また、Flashコンテンツについても、ユーザーのアクションなしには実行できないようになると発表されました。

MacにはFlash Playerがプリインストールされていないので、インストールしていない人もいるかと思います(Chrome内蔵のFlash Player経由で見るなどは大いにありそうですが)。他のブラウザーもFlashをなるべく実行しないように動いていますし、HTML5なコンテンツへの移行が急務になりそうですね。

EdgeとProgressive Web Apps

Microsoft EdgeチームのJacob Rossiが、MediumにWindowsにおけるWebアプリのこれまでと今後のProgressive Web Apps対応について投稿していました。

WindowsにおけるProgressive Web Apps対応は、ChromeやOperaのそれよりも、よりOSの機能との統合を考えているようです。具体的な例としてアプリケーションのリストへの追加や、アプリの設定やアンインストールといったものを紹介しています。また、WindowsストアやBingの検索結果への表示といった、アプリの発見についてもいろいろ考えている模様。PWAをふつうのアプリとして扱うよという強いメッセージを発しています。

この記事と時を同じくして、Push API, Web App Manifest, Cache APIの実装開始も発表されました。Windowsというデスクトップ環境が主流のプラットフォームでWebアプリをどう統合していくか、とても楽しみです。

]]>
https://html5experts.jp/myakura/19921/feed/ 0
手動でWebRTCの通信をつなげよう ーWebRTC入門2016 https://html5experts.jp/mganeko/19814/ https://html5experts.jp/mganeko/19814/#comments Fri, 01 Jul 2016 02:13:12 +0000 https://html5experts.jp/?p=19814 連載: WebRTC入門2016 (2)

こんにちは! がねこまさしです。2014年に連載した「WebRTCを使ってみよう!」シリーズを、2016年6月の最新情報に基づき、内容をアップデートして改めてお届けしています。1回目はカメラにアクセスしてみました。2回目となる今回は、WebRTCの通信の仕組みを実感するために、「手動」でP2P通信をつなげてみましょう。

WebRTCの通信はどうなっているの?

WebRTCでは、映像/音声/アプリケーションデータなどをリアルタイムにブラウザ間で送受信することができます。それをつかさどるのが「RTCPeerConnection」です。 RTCPeerConnectionには3つの特徴があります。

  • Peer-to-Peer(P2P)の通信 → ブラウザとブラウザの間で直接通信する
  • UDP/IPを使用 → TCP/IPのようにパケットの到着は保障しないが、オーバーヘッドが少ない
  • PeerとPeerの間で暗号化通信を行う → P2P通信の前に鍵の交換を行う

多少の情報の欠落があっても許容する替わりに、通信のリアルタイム性を重視しています。UDPのポート番号は動的に割り振られ、49152 ~ 65535の範囲が使われるようです。 rtcpeerconnection

P2P通信を確立するまで

ブラウザ間でP2P通信を行うには、相手のIPアドレスを知らなくてはなりませんし、動的に割り振られるUDPのポート番号も知る必要があります。また、その通信でやり取りできる内容についても、お互い合意しておく必要があります。そのためP2P通信が確立するまでに、WebRTCではいくつかの情報をやり取りしています。

Session Description Protocol (SDP)

各ブラウザが通信した内容を示し、テキストで表現されます。例えば次のような情報を含んでいます。

  • 通信するメディアの種類(音声、映像)、メディアの形式(コーデック)、アプリケーションデータ
  • IPアドレス、ポート番号
  • 暗号化の鍵
  • セッションの属性(名前、識別子、アクティブな時間など)→ WebRTCでは使っていないようです

rtcpeer_ip_port

ICE Candidate

P2P通信を行う際にどのような通信経路が使えるかは、お互いのネットワーク環境に依存します。通信経路を定めるための仕組みが「Interactive Connectivity Establishment (ICE)」で、その通信経路の候補が「ICE Candidate」になります。WebRTCの通信を始める前に、可能性のある候補がリストアップされます。

  • P2Pによる直接通信
  • NATを通過するためのSTUNサーバーから取得したポートマッピング → 最終的にはP2Pになる
  • Firefallを越えるための、TURNによるリレーサーバーを介した中継通信

候補が見つかったら順次通信を試み、最初につながった経路が採用されます。

手動シグナリングを実験してみよう

このように、P2Pを始めるまでの情報のやり取りを「シグナリング」と言います。実は、WebRTCではシグナリングのプロトコルは規定されていません(自由に選べます)。シグナリングを実現するにはWebSocketを使うなど複数の方法がありますが、今回は最も原始的な方法であるコピー&ペーストを試してみましょう。

ちょっと長いですが、こちらのHTMLをお好きなWebサーバーに配置してください。

<!doctype html>
<html>
<head>
 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 <title>Hand Signaling</title>
</head>
<body>
  Hand Signaling 2016<br />
  <button type="button" onclick="startVideo();">Start Video</button>
  <button type="button" onclick="stopVideo();">Stop Video</button>
  &nbsp;
  <button type="button" onclick="connect();">Connect</button>
  <button type="button" onclick="hangUp();">Hang Up</button> 
  <div>
    <video id="local_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
    <video id="remote_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
  </div>
  <p>SDP to send:<br />
    <textarea id="text_for_send_sdp" rows="5" cols="60" readonly="readonly">SDP to send</textarea>
  </p>
  <p>SDP to receive:&nbsp;
    <button type="button" onclick="onSdpText();">Receive remote SDP</button><br />
    <textarea id="text_for_receive_sdp" rows="5" cols="60"></textarea>
  </p>
</body>
<script type="text/javascript">
  let localVideo = document.getElementById('local_video');
  let remoteVideo = document.getElementById('remote_video');
  let localStream = null;
  let peerConnection = null;
  let textForSendSdp = document.getElementById('text_for_send_sdp');
  let textToReceiveSdp = document.getElementById('text_for_receive_sdp');

  // --- prefix -----
  navigator.getUserMedia  = navigator.getUserMedia    || navigator.webkitGetUserMedia ||
                            navigator.mozGetUserMedia || navigator.msGetUserMedia;
  RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
  RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;

  // ---------------------- media handling ----------------------- 
  // start local video
  function startVideo() {
    getDeviceStream({video: true, audio: false})
    .then(function (stream) { // success
      localStream = stream;
      playVideo(localVideo, stream);
    }).catch(function (error) { // error
      console.error('getUserMedia error:', error);
      return;
    });
  }

  // stop local video
  function stopVideo() {
    pauseVideo(localVideo);
    stopLocalStream(localStream);
  }

  function stopLocalStream(stream) {
    let tracks = stream.getTracks();
    if (! tracks) {
      console.warn('NO tracks');
      return;
    }
    
    for (let track of tracks) {
      track.stop();
    }
  }
  
  function getDeviceStream(option) {
    if ('getUserMedia' in navigator.mediaDevices) {
      console.log('navigator.mediaDevices.getUserMadia');
      return navigator.mediaDevices.getUserMedia(option);
    }
    else {
      console.log('wrap navigator.getUserMadia with Promise');
      return new Promise(function(resolve, reject){    
        navigator.getUserMedia(option,
          resolve,
          reject
        );
      });      
    }
  }

  function playVideo(element, stream) {
    if ('srcObject' in element) {
      element.srcObject = stream;
    }
    else {
      element.src = window.URL.createObjectURL(stream);
    }
    element.play();
    element.volume = 0;
  }

  function pauseVideo(element) {
    element.pause();
    if ('srcObject' in element) {
      element.srcObject = null;
    }
    else {
      if (element.src && (element.src !== '') ) {
        window.URL.revokeObjectURL(element.src);
      }
      element.src = '';
    }
  }

  // ----- hand signaling ----
  function onSdpText() {
    let text = textToReceiveSdp.value;
    if (peerConnection) {
      console.log('Received answer text...');
      let answer = new RTCSessionDescription({
        type : 'answer',
        sdp : text,
      });
      setAnswer(answer);
    }
    else {
      console.log('Received offer text...');
      let offer = new RTCSessionDescription({
        type : 'offer',
        sdp : text,
      });
      setOffer(offer);
    }
    textToReceiveSdp.value ='';
  }
 
  function sendSdp(sessionDescription) {
    console.log('---sending sdp ---');
    textForSendSdp.value = sessionDescription.sdp;
    textForSendSdp.focus();
    textForSendSdp.select();
  }

  // ---------------------- connection handling -----------------------
  function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

    // --- on get remote stream ---
    if ('ontrack' in peer) {
      peer.ontrack = function(event) {
        console.log('-- peer.ontrack()');
        let stream = event.streams[0];
        playVideo(remoteVideo, stream);
      };
    }
    else {
      peer.onaddstream = function(event) {
        console.log('-- peer.onaddstream()');
        let stream = event.stream;
        playVideo(remoteVideo, stream);
      };
    }

    // --- on get local ICE candidate
    peer.onicecandidate = function (evt) {
      if (evt.candidate) {
        console.log(evt.candidate);

        // Trickle ICE の場合は、ICE candidateを相手に送る
        // Vanilla ICE の場合には、何もしない
      } else {
        console.log('empty ice event');

        // Trickle ICE の場合は、何もしない
        // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
        sendSdp(peer.localDescription);
      }
    };

    
    // -- add local stream --
    if (localStream) {
      console.log('Adding local stream...');
      peer.addStream(localStream);
    }
    else {
      console.warn('no local stream, but continue.');
    }

    return peer;
  }

  function makeOffer() {
    peerConnection = prepareNewConnection();
    peerConnection.createOffer()
    .then(function (sessionDescription) {
      console.log('createOffer() succsess in promise');
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      console.log('setLocalDescription() succsess in promise');

      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      // -- Vanilla ICE の場合には、まだSDPは送らない --
      //sendSdp(peerConnection.localDescription);
    }).catch(function(err) {
      console.error(err);
    });
  }

  function setOffer(sessionDescription) {
    if (peerConnection) {
      console.error('peerConnection alreay exist!');
    }
    peerConnection = prepareNewConnection();
    peerConnection.setRemoteDescription(sessionDescription)
    .then(function() {
      console.log('setRemoteDescription(offer) succsess in promise');
      makeAnswer();
    }).catch(function(err) {
      console.error('setRemoteDescription(offer) ERROR: ', err);
    });
  }
  
  function makeAnswer() {
    console.log('sending Answer. Creating remote session description...' );
    if (! peerConnection) {
      console.error('peerConnection NOT exist!');
      return;
    }
    
    peerConnection.createAnswer()
    .then(function (sessionDescription) {
      console.log('createAnswer() succsess in promise');
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      console.log('setLocalDescription() succsess in promise');

      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      // -- Vanilla ICE の場合には、まだSDPは送らない --
      //sendSdp(peerConnection.localDescription);
    }).catch(function(err) {
      console.error(err);
    });
  }

  function setAnswer(sessionDescription) {
    if (! peerConnection) {
      console.error('peerConnection NOT exist!');
      return;
    }

    peerConnection.setRemoteDescription(sessionDescription)
    .then(function() {
      console.log('setRemoteDescription(answer) succsess in promise');
    }).catch(function(err) {
      console.error('setRemoteDescription(answer) ERROR: ', err);
    });
  }
  
  // start PeerConnection
  function connect() {
    if (! peerConnection) {
      console.log('make Offer');
      makeOffer();
    }
    else {
      console.warn('peer already exist.');
    }
  }

  // close PeerConnection
  function hangUp() {
    if (peerConnection) {
      console.log('Hang up.');
      peerConnection.close();
      peerConnection = null;
      pauseVideo(remoteVideo);
    }
    else {
      console.warn('peer NOT exist.');
    }
  }

</script>
</html>

GitHub Pagesでも公開していますので、すぐに試すことができます。

次に、PCにWebカメラを接続してからFirefox 47 またはChrome 51でアクセスしてみてください。(※Chromeの場合はカメラ映像取得の制限があるので、https://~か http://localhost/~のWebサーバーが必要になります)残念ながら今回はEdgeでは利用できません。

通信するために2つページを開く必要があります。同一ウィンドウで複数タブを開くよりも、別のウィンドウで横に並べたほうが作業しやすいです。

接続手順

接続手順は2014年のものよりも簡略化しました。それでも間違えやすいので慎重に操作してくださいね。

(1) 映像の取得

両方のウィンドウで[Start Video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。
hand2016_1

(2) 通信の開始

左のウィンドウで[Connect]ボタンをクリックしてください。すると[SDP to send:]のテキストエリアに、ドバドバっとSDPの文字列が表示されます。
hand2016_2

(3)(4) SDPの送信(左→右)

(3)左の[SDP to send:]の内容をコピーし、(4)右の[SDP to receive:]の下のテキストエリアにペーストします。
hand2016_3_4

(5) SDPの受信(右)

右の[Receive remote SDP]ボタンをクリックすると、今度は右のウィンドウの[SDP to send:]のテキストエリアに、ドバドバっとSDPの文字列が表示されます。
hand2016_5

(6)(7) SDPの返信(左←右)

さっきと反対に(6)右の[SDP to send:]の内容をコピーし、(7)左の[SDP to receive:]の下のテキストエリアにペーストします。
hand2016_6_7

(8) SDPの受信(左)

左の[Receive remote SDP]ボタンをクリックします。しばらくすると(~数秒)P2P通信が始まり両方のウィンドウに2つ目の動画が表示されるはずです。
hand2016_8

上手くいかなかった場合は、コピー範囲が欠けているか、手順が抜けている可能性があります。深呼吸してから両方のブラウザをリロードし、もう一度試してみてください。

それでも通信できない場合は、実はネットワーク環境の問題の可能性があります。この記事の「トラブルシューティング」の章をご覧ください。(あるいはソースのバグや、説明の不備の可能性もあります。何かお気づきの際にはご指摘ください)

トラブルシューティング

これまで何度も手動シグナリングを試して/試していただいて、通信ができないケースがありました。当初は原因が分からなかったのですが、その後に判明したケースを説明します。

外部ネットワークにつながっていない場合

PCが全くネットワークに接続されていない状態では、カメラ映像の取得に成功しても通信ができません。これはネットワークに接続されていない状態では、通信経路の情報であるICE Candidateが収集できないためです。
例え同一PC内で通信を行う場合にも、外部に接続できる状態で利用する必要があります。

ハンズオン等で手動シグナリングを試してもらうことがあるのですが、長いことこの制約に気が付かず通信できないで悩んでいました。

Chrome – Firefox 間での通信

WebRTCではChrome – Chrome間や、Firefox – Firefox 間のように同一種類のブラウザ同士だけでなく、Chrome – Firefox間でも通信することができます。もちろん手動シグナリングでも同様です。
ところが実際に1台のPCで Firefox – Chrome 間で手動シグナリングを行おうとすると、カメラ映像の取得で衝突してしまうケースがあります。この場合は次のどちらかをお試しください

  • (a) 2台のカメラをご用意して、ブラウザごとに違うカメラの映像を取得する
  • (b) 映像は片方のブラウザのみで取得し、そのブラウザから[Connect]で通信を始める
    • → ※この場合は片方向の映像通信となります

裏側で起こっていること

それでは映像通信に成功したところで、その裏側で起きていることを見てみましょう。

Vanilla ICE と Trickle ICE

WebRTCのP2P通信を確立するためのシグナリングでは、次の2種類の情報を交換する必要があると説明しました。

  • SDP
  • ICE candidate

ところが今回の手動シグナリングではSDPしか交換していません。いったいぜんたい、ICE candidateの情報はどうなっているのでしょうか?

実はICE Candidateの情報は、今回交換しているSDPの中に含まれています。実際に私のPCで取得したSDPの一部を掲載します。(※IPアドレスは一部マスクしています)

m=video 58461 UDP/TLS/RTP/SAVPF 100 101 116 117 96 97 98
c=IN IP4 192.168.xxx.xxx
a=rtcp:58465 IN IP4 192.168.xxx.xxx
a=candidate:2999745851 1 udp 2122260223 192.168.xxx.xxx 58461 typ host generation 0 network-id 4
a=candidate:2747735740 1 udp 2122194687 192.168.xxx.xxx 58462 typ host generation 0 network-id 3
a=candidate:1606961068 1 udp 2122129151 10.2.xxx.xxx 58463 typ host generation 0 network-id 2
a=candidate:1435463253 1 udp 2122063615 192.168.xxx.xxx 58464 typ host generation 0 network-id 1
a=candidate:2999745851 2 udp 2122260222 192.168.xxx.xxx 58465 typ host generation 0 network-id 4
a=candidate:2747735740 2 udp 2122194686 192.168.xxx.xxx 58466 typ host generation 0 network-id 3
a=candidate:1606961068 2 udp 2122129150 10.2.xxx.xxx 58467 typ host generation 0 network-id 2
a=candidate:1435463253 2 udp 2122063614 192.168.xxx.xxx 58468 typ host generation 0 network-id 1
a=candidate:4233069003 1 tcp 1518280447 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 4
a=candidate:3980714572 1 tcp 1518214911 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 3
a=candidate:290175836 1 tcp 1518149375 10.2.xxx.xxx 9 typ host tcptype active generation 0 network-id 2
a=candidate:453808805 1 tcp 1518083839 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 1
a=candidate:4233069003 2 tcp 1518280446 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 4
a=candidate:3980714572 2 tcp 1518214910 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 3
a=candidate:290175836 2 tcp 1518149374 10.2.xxx.xxx 9 typ host tcptype active generation 0 network-id 2
a=candidate:453808805 2 tcp 1518083838 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 1
a=ice-ufrag:xxxxxxxxxxxxx
a=ice-pwd:xxxxxxxxxxxx

a=candidate: で始まる行がICE candidateになります。(※仮想化ソフトを入れている影響で複数のネットワークが候補になっています)。 SDPを最初に取得したときにはICE candidateの行は含まれず、その後ICE candidateが収集されるにしたがって、SDPの中に追加されます。
今回は全てのICE candidateが出そろった後に、SDPとまとめて交換しています。このような方式を “Vanilla ICE” と呼びます。
hand2016_vanilla

これに対して、初期のSDPを交換し、その後ICE Candidateを順次交換する方式を “Trickle ICE” と呼びます。すべてのICE candidateを交換し終わる前にP2P通信が始まることがあるので、Trickle ICEの方が一般的に早く接続が確立します。
hand2016_trickle

Offer と Answer

SDPには通信を始める側(Offer)と、通信を受け入れる側(Answer)があります。必ずOffer → Answerの順番でやりとりする必要があります。

ソースコードを追いかけてみよう

Offer SDPの生成

それでは、SDP(+ ICE candidate)のやり取りをソースコードで見てみましょう。まずは[Connect]ボタンを押してSDPを生成するところまでです。(ソースコードは抜粋しています)

// start PeerConnection
  function connect() {
      makeOffer();
  }

  // Offer SDPを生成する
  function makeOffer() {
    peerConnection = prepareNewConnection(); // RTCPeerConnectionを生成し、必要なメッセージハンドラを設定

    peerConnection.createOffer()
    .then(function (sessionDescription) {
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      // -- Vanilla ICE の場合には、まだSDPは送らない --
      //sendSdp(peerConnection.localDescription);  <-- Vanilla ICEなので、まだ送らない
    }).catch(function(err) {
      console.error(err);
    });
  }

発信側で[Connect]ボタンをクリックすると、次の処理が行われます。

  • RTCPeerConnection のオブジェクトを生成
  • RTCPeerConnection.createOffer() で Offer SDPを生成
  • 生成したOffer SDPを、RTCPeerConnection.setLocalDescription()で覚える
  • Trickle ICEの場合はすぐにSDPを送信するが、今回は送信しない

createOffer(), setLocalDescription()は非同期で処理が行われます。従来はコールバックで後続処理を記述していましたが、現在はPromiseを返すので、then()の中に処理を記述します。
2014年の記事ではsetLocalDescription()が非同期であることを意識しおらず、誤った記述になっていました。

ICE candidateの収集

次は ICE candidateの収集です。ICE candidateの収集も非同期に行われるため、RTCPeerConnectionのイベントハンドラで行います。

function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

    // --- on get local ICE candidate
    peer.onicecandidate = function (evt) {
      if (evt.candidate) { // ICE candidate が収集された場合
        console.log(evt.candidate);

        // Trickle ICE の場合は、ICE candidateを相手に送る
        // Vanilla ICE の場合には、何もしない
      } else { // ICE candidateの収集が完了した場合
        console.log('empty ice event');

        // Trickle ICE の場合は、何もしない
        // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
        sendSdp(peer.localDescription);
      }
    };

    // ... 省略 ....

    // 通信対象の映像/音声ストリームを追加する
    if (localStream) {
      console.log('Adding local stream...');
      peer.addStream(localStream);
    }


    return peer;
  }

今回のコードではprepareNewConnection()の中でRTCPeerConnectionオブジェクトを生成し、各種イベントハンドラを設定しています。ICE candidateのためRTCPeerConnection.onicecandidateにイベントハンドラを記述しています。このイベントは複数回発生します。
全てのICE candidateを収集し終わると空のイベントが渡ってきます。このタイミングで最終的なSDPを相手に送信します。今回の手動シグナリングではsendSdp()の中でテキストエリアに表示しています。

Offser SDPの受信

応答側にOffer SDPをペーストして[Receive remote SDP]ボタンをクリックすると、onSdpText() → setOffer() と呼び出されます。

function onSdpText() {
    let text = textToReceiveSdp.value;
    if (peerConnection) { // Answerの場合
      // ... 省略 ...
    }
    else { // Offerの場合
      let offer = new RTCSessionDescription({
        type : 'offer',
        sdp : text,
      });
      setOffer(offer);
    }
    textToReceiveSdp.value ='';
  }

さらに setOffer()の中では次の処理が行われています。

  • PeerConnectionのオブジェクトを生成
  • 受け取ったOffer SDPを setRemoteDescription()で覚える。Promiseを使った非同期処理を行う
  • 成功したら makeAnswer()の中でAnswer SDPを生成
    function setOffer(sessionDescription) {
        peerConnection = prepareNewConnection();
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
          makeAnswer();
        }).catch(function(err) {
          console.error('setRemoteDescription(offer) ERROR: ', err);
        });
      }

Answer SDPの生成→送信

makeAnswer()の中ではOfferの時と同様な処理が行われます。

function makeAnswer() {   
    peerConnection.createAnswer()
    .then(function (sessionDescription) {
      return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
      // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
      // -- Vanilla ICE の場合には、まだSDPは送らない --
      //sendSdp(peerConnection.localDescription);
    }).catch(function(err) {
      console.error(err);
    });
  }

  • RTCPeerConnection.createAnswer() で Answer SDPを生成
  • 生成したAnswer SDPを、RTCPeerConnection.setLocalDescription()で覚える。Promiseを使った非同期処理を行う
  • Trickle ICEの場合はすぐにSDPを送信するが、今回は送信しない

この後 RTCPeerConnection.onicecandidate()でICE candidateを収集し、すべて揃ったらsendSdp()でOffer側に送り返します。

Answer SDPの受信

発信側にAnser SDPをペーストして[Receive remote SDP]ボタンをクリックすると、onSdpText() → setAnswer() と呼び出されます。

function onSdpText() {
    let text = textToReceiveSdp.value;
    if (peerConnection) { // Answerの場合
      let answer = new RTCSessionDescription({
        type : 'answer',
        sdp : text,
      });
      setAnswer(answer);
    }
    else { // Offerの場合
      // ... 省略 ...
    }
    textToReceiveSdp.value ='';
  }

  function setAnswer(sessionDescription) {
    peerConnection.setRemoteDescription(sessionDescription)
    .then(function() {
      console.log('setRemoteDescription(answer) succsess in promise');
    }).catch(function(err) {
      console.error('setRemoteDescription(answer) ERROR: ', err);
    });
  }

setAnswer()の中ではRTCPeerConnection.setRemoteDescription()で受け取ったSDPを覚えます。

映像/音声の送受信

PeerConnectionのオブジェクトを生成した際に、送信する映像/音声ストリームをRTCPeerConnection.addStream()で指定しておきます。

function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

    // ... 省略 ...

    // -- add local stream --
    if (localStream) {
      peer.addStream(localStream);
    }

    return peer;
  }

SDPの交換が終わると、P2P通信に相手の映像/音声が含まれていればイベントが発生します。従来はRTCPeerConnection.onaddstream() にハンドラを記述していましたが、新しいイベントが策定されRTCPeerConnection.ontrack() にハンドラを記述するようになっています。Firefoxはontrack()がすでに使えるようになっていて、onaddstream()は非推奨になっています。(Chromeは未対応です)

function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

    // --- on get remote stream ---
    if ('ontrack' in peer) {
      peer.ontrack = function(event) {
        let stream = event.streams[0];
        playVideo(remoteVideo, stream);
      };
    }
    else {
      peer.onaddstream = function(event) {
        let stream = event.stream;
        playVideo(remoteVideo, stream);
      };
    }

    // ... 省略 ....
  }

以上で主要な処理の解説は終わりです。

次回は

今回は手動で情報交換を行い、原始的なビデオチャットを動かしてみました。P2P通信が確立するまでの動きを実感していただけたのではないでしょうか?

実際の利用場面では手動シグナリングなんかやってらません。次回はシグナリングサーバーを使って、通信を行ってみたいと思います。

]]>
https://html5experts.jp/mganeko/19814/feed/ 0
FirebaseとAngular2を使ってリアルタイムでデータのやり取り【導入編】 https://html5experts.jp/frontainer/19689/ https://html5experts.jp/frontainer/19689/#comments Mon, 27 Jun 2016 00:00:18 +0000 https://html5experts.jp/?p=19689 連載: Angular2で学ぶFirebase入門 (1)

Firebaseはリアルタイム同期なデータベースを中心に、Auth認証やPushNotificationやユーザーの行動分析といったアプリケーションに最適な機能を提供するBasSです。先月行われたGoogle IOにて、PushNotificationや行動分析機能が追加されるなど話題となりました。

今回はFirebaseのリアルタイムベースとAngular2を用いて、リアルタイムにデータをやり取りする方法について解説します。

Firebaseのリアルタイムデータベース

Firebaseのリアルタイムデータベースはクラウドホスト型のNoSQLデータベースです。JSONの形式でデータは保存されます。 APIはもちろん、iOS/Android/Web用のSDKが提供されているので、非常に簡単に導入することができます。

また、ネットワークが途中で切れた場合は自動的に再接続を行い、復帰後に改めてデータが同期されます。同時100コネクション、1GBのデータ、10GBの転送量までは無料で使うことができるので、まず試してみるといったことができます。

今回はこの無料プランとAngular2を使って進めていきます。

Angular CLIで環境構築

Angular2の環境構築が初めての方でも簡単に導入できるように、Angular2ではCLIが公開されています。これを利用して環境を構築していきます。(※別途、Node.jsのインストールが必要です)

angular-cli

まずはnpmを使って、angular-cliをインストールします。

npm install -g angular-cli

インストールが成功していれば、ng versionでバージョンが表示されます。

angular-cli: 1.0.0-beta.5
node: 5.6.0
os: darwin x64

続いて、ng newコマンドを用いてプロジェクトを作成します。newの後には任意のプロジェクト名を入力してください。(angularやfirebaseという名称を用いるとパッケージ名が重複してしまうため、関連ライブラリのインストールに失敗するのでご注意ください)

ng new sample
cd sample

今回はng new sampleでプロジェクトを作成します。

sampleというフォルダができるのでcd sampleで移動し、この状態で ng serve コマンドを実行するとサンプルのアプリケーションがビルドされ、http://localhost:4200にブラウザでアクセスするとsample works!と表示されます。

これで開発環境の準備が整いました。

AngularFire導入

本題のAngularFireを導入していきます。

angularfire2

npm install angularfire2 firebase@2.4.2 --save

※ 6月14日現在の最新版は、firebase 2.4.2に対応しています。

続いて、firebase用の型定義ファイルをインストールします。 typingsがインストールされていない場合は npm i -g typingsを実行してインストールをしてください。

typings install dt~firebase --save --global

※ 注意

typings 1系と0系では、コマンドやインストールされるファイルの構成が異なります。現在のバージョンのangular-cliでは0系でインストールされますが、npmでインストールされる最新版は1系になります。本稿では1系がインストールされている状態で解説を行います。0系の場合は以下のtypingsに追記する作業は必要ありません。

src/typings.d.tsにインストールされたfirebaseの型定義ファイル群を追記します。

/// <reference path="../typings/browser.d.ts" />
/// <reference path="../typings/index.d.ts" />
declare var module: { id: string };

angular-cli-build.jsを開き、vendorNpmFiles'angularfire2/**/*.js' 'firebase/lib/*.js' を追記します。

var Angular2App = require('angular-cli/lib/broccoli/angular2-app');

module.exports = function(defaults) {
  return new Angular2App(defaults, {
    vendorNpmFiles: [
      'systemjs/dist/system-polyfills.js',
      'systemjs/dist/system.src.js',
      'zone.js/dist/**/*.+(js|js.map)',
      'es6-shim/es6-shim.js',
      'reflect-metadata/**/*.+(js|js.map)',
      'rxjs/**/*.+(js|js.map)',
      '@angular/**/*.+(js|js.map)',
      'angularfire2/**/*.js', // <- 追加
      'firebase/lib/*.js'      // <- 追加
    ]
  });
};

続いて、/src/system-config.tsに以下のようにfirebase,angularfire2の記述を追記します。

/** Map relative paths to URLs. */
const map: any = {
  'firebase': 'vendor/firebase/lib/firebase-web.js',
  'angularfire2': 'vendor/angularfire2'
};

/** User packages configuration. */
const packages: any = {
  angularfire2: {
    defaultExtension: 'js',
    main: 'angularfire2.js'
  }
}

これでAngular2でFirebaseを使う準備が整いました。

Firebaseの登録

続いて、Firebaseの登録と設定を行っていきましょう。

firebase

Screen Shot 0028-06-14 at 22.46.07

FirebaseはGoogleアカウントで登録することができます。登録が完了すると、プロジェクトを作るためのボタンが表示されます。

Screen Shot 0028-06-14 at 22.47.30

「新規プロジェクトを作成」ボタンをクリックして、アプリケーションを作成しましょう。

Screen Shot 0028-06-14 at 22.48.17

アプリケーションが作成できたら「Database」ページを開きます。 以下のように、まだデータベースには何も入っていません。まずはこの画面から初期データを登録してみましょう。

Screen Shot 0028-06-14 at 22.55.35

このようにデータを作成しました。

{
  items: {
    0: {
      value: 'item0001'
    },
    2: {
      value: 'item0002'
    },
    2: {
      value: 'item0003'
    }
  }
}

Firebase上では、すべてオブジェクトの形式でデータが構成されるようにしておきます。

続いて、「ルール」のタブをクリックしてルールを設定します。 ルールはデータベースへの値に対してのアクセス権の設定を行うことができます。JSONの形式でデータを定義していきます。

Screen Shot 0028-06-14 at 22.58.07

詳しいルールの記載は、以下のページに説明があります。 https://html5exp-sample.firebaseio.com/

ここではサンプルということで、読み書きともに誰からでもできるように設定しておきましょう。

{
  "rules": {
    "items": {
      ".read": "true",
      ".write": "true"
    }
  }
}

これによってデータベースのitems以下は、読み書きともに誰でも許可されるようになりました。では、これで登録したデータが読み取れるか試してみましょう。/src/main.tsを開きます。

import { bootstrap } from '@angular/platform-browser-dynamic';
import { enableProdMode } from '@angular/core';
import { SampleAppComponent, environment } from './app/';
import { FIREBASE_PROVIDERS, defaultFirebase } from 'angularfire2'; // <- add

if (environment.production) {
  enableProdMode();
}

bootstrap(SampleAppComponent,[
  FIREBASE_PROVIDERS, // <- add
  defaultFirebase('https://<your-firebase-app>.firebaseio.com') // <- add
]);

main.ts にangularfire2のファイルをimportし、bootstrap関数の第2引数にFIREBASE_PROVIDERSdefaultFirebase('https://.firebaseio.com') を配列で渡しておきます。

こうすることによって、今後Angular2のコンポーネント内でFirebaseに必要な設定とプロバイダが提供されるようになります。

続いて、画面にデータを表示するためのコンポーネントに変更を加えます。

import { Component } from '@angular/core';
import { AngularFire, FirebaseListObservable } from 'angularfire2'; //<- add

@Component({
  moduleId: module.id,
  selector: 'sample-app',
  templateUrl: 'sample.component.html',
  styleUrls: ['sample.component.css']
})
export class SampleAppComponent {
  title = 'sample works!';
  items: FirebaseListObservable<any[]>;
  constructor(af: AngularFire) {
		this.items = af.database.list('/items');
  }
}

<h1>{{title}}</h1>
<ul *ngFor="let item of items | async">
  <li class="text">
    {{item.value}}
  </li>
</ul>

*ngFor="let item of items | async" のasyncはAsyncPipeと呼ばれるAngular2にはじめから組み込まれているPipeのひとつです。これはitemsのObservableをsubscribeしたときの引数を自動的に取得して表示してくれるPipeです。詳しい使い方や解説は以下のページにありますので、こちらをご一読ください。

Async Pipe

ng serve コマンドを改めて実行すると、Firebaseで登録されたデータが表示されるようになります。

データの追加

次にブラウザからデータを追加する処理を実装していきましょう。

export class SampleAppComponent {
  title = 'sample works!';
  items: FirebaseListObservable<any[]>;
  constructor(af: AngularFire) {
    this.items = af.database.list('/items');
  }
  addItem(value:string) {
    this.items.push({value:value});
  }
}

<h1>{{title}}</h1>
<ul *ngFor="let item of items | async">
  <li class="text">
    {{item.value}}
  </li>
</ul>

<form (ngSubmit)="addItem(newItem.value);newItem.value=''">
  <input type="text" #newItem>
  <button>send</button>
</form>

FirebaseListObservableにデータをpushするだけで配列データにデータを追加することができます。

データの更新

今度はリストのデータを更新してみましょう。

export class SampleAppComponent {
  title = 'sample works!';
  items: FirebaseListObservable<any[]>;
  constructor(af: AngularFire) {
    this.items = af.database.list('/items');
  }
  addItem(value:string) {
    this.items.push({value:value});
  }
  updateItem(key:string,value:string) {
	  this.items.update(key,{value:value});
  }
}

<h1>{{title}}</h1>
<ul *ngFor="let item of items | async">
  <li class="text">
    {{item.value}}
    <input [(ngModel)]="item.value" (keyup)="updateItem(item.$key,item.value)"/>
  </li>
</ul>

<form (ngSubmit)="addItem(newItem.value);newItem.value=''">
  <input type="text" #newItem>
  <button>send</button>
</form>

this.items.update(key,{value:value});のように更新したいデータの$keyと値を渡すことで更新ができます。 Firebaseのオブジェクトには$keyというプロパティがあり、各データのKey名を取得することができます。 pushで追加されたデータは、一意なKeyが自動で振られるようになっています。

データの削除

続いて、リストのデータを削除してみましょう。

export class SampleAppComponent {
  title = 'sample works!';
  items: FirebaseListObservable<any[]>;
  constructor(af: AngularFire) {
    this.items = af.database.list('/items');
  }
  addItem(value:string) {
    this.items.push({value:value});
  }
  updateItem(key:string,value:string) {
	  this.items.update(key,{value:value});
  }
  deleteItem(key:string) {
	  this.items.remove(key);
  }
  deleteAll() {
	  this.items.remove();
  }
}

<h1>{{title}}</h1>
<ul *ngFor="let item of items | async">
  <li class="text">
    {{item.value}}
    <input [(ngModel)]="item.value" (keyup)="updateItem(item.$key,item.value)"/>
    
    <button (click)="removeItem(item.$key)">削除</button>
  </li>
</ul>

<form (ngSubmit)="addItem(newItem.value);newItem.value=''">
  <input type="text" #newItem>
  <button>send</button>
</form>

<button (click)="removeAll()">全削除</button>

更新と同様にthis.items.remove(key);と削除したいデータのKeyを渡すことで、そのデータのみを削除することができます。

this.items.remove();とKeyを渡さずにremoveするとリスト全体が削除されるので注意してください。

このように非常に簡単にデータの読み書きが実装できてしまいます。

オブジェクトデータの取り扱い

続いて、今度はリストデータでなく単一のオブジェクトのデータを扱ってみましょう。Firebaseの管理画面でDatabaseのデータに以下のようなconstantsを追加しました。

{
  constants: {
    title: "My first firebase app"
  },
  items: {
    ...
  }
}

ルール設定ページにいき、constantsは読み込み専用に設定しておきます。

{
  "rules": {
    "constants": {
      ".read": "true",
      ".write": "false"
    },
    "items": {
      ".read": "true",
      ".write": "true"
    }
  }
}

src/app/sample.component.tsを、以下のように修正します。

import { Component } from '@angular/core';
import { AngularFire, FirebaseListObservable, FirebaseObjectObservable } from 'angularfire2'; //<- add

@Component({
  moduleId: module.id,
  selector: 'sample-app',
  templateUrl: 'sample.component.html',
  styleUrls: ['sample.component.css']
})
export class SampleAppComponent {
  constants: FirebaseObjectObservable<any>;
  items: FirebaseListObservable<any[]>;
  constructor(af: AngularFire) {
    this.constants = af.database.object('/constants');
    this.items = af.database.list('/items');
  }
  addItem(value:string) {
    this.items.push({value:value});
  }
  updateItem(key:string,value:string) {
    this.items.update(key,{value:value});
  }
  deleteItem(key:string) {
    this.items.remove(key);
  }
  deleteAll() {
    this.items.remove();
  }
}

そして、ビューのタイトル部分をconstantsから取得して表示させるようにします。

<h1>{{(constants | async)?.title}}</h1>
<ul *ngFor="let item of items | async">
  <li class="text">
    <input [(ngModel)]="item.value" (keyup)="updateItem(item.$key,item.value)"/>

    <button (click)="deleteItem(item.$key)">削除</button>
  </li>
</ul>

<form (ngSubmit)="addItem(newItem.value);newItem.value=''">
  <input type="text" #newItem>
  <button>send</button>
</form>

<button (click)="deleteAll()">全削除</button>

こうすることで、オブジェクトのデータを読み込んで画面に表示させることができました。

オブジェクトの更新

オブジェクトの更新をする際は、リストと同様にupdate関数を用いて変更を行うことができます。その時にはルールで書き込みが許可されている必要があります。

this.constants.update({title:"new title"});

オブジェクトの削除

オブジェクトの削除を行う際にはremove関数を用いますが、以下のようにremoveするとconstantsごと削除されます。

this.constants.remove(); // constantsごと削除

1つのプロパティのみを削除したい場合は、以下のように削除したいプロパティまで取得した上でremove関数を実行します。

af.database.object('/constants/title').remove(); //titleだけ削除

まとめ

このようにAngular2とFirebaseを使うことで、簡単にリアルタイムにデータのやり取りを行うことができるようになります。

今回はサンプルということで誰でも読み書きできる形にしていますが、ユーザー認証をFirebaseで作ったり、既にあるログイン情報を利用して認証させたりできるので、新規でも追加でも導入しやすくなっています。

次回はその認証機能を用いてログインすると書き込みができるようにしていきたいと思います。

]]>
https://html5experts.jp/frontainer/19689/feed/ 0
カメラを使ってみよう ーWebRTC入門2016 https://html5experts.jp/mganeko/19728/ https://html5experts.jp/mganeko/19728/#comments Fri, 24 Jun 2016 00:00:28 +0000 https://html5experts.jp/?p=19728 連載: WebRTC入門2016 (1)

こんにちは! がねこまさしです。2014年に連載した「WebRTCを使ってみよう!」シリーズですが、内容がすっかり古くなってしまいました。そこで2016年6月の最新情報に基づき、内容をアップデートして改めてお届けしたいと思います。

WebRTCとは?

WebRTCとは”Web Real-Time Communication”の略で、Webブラウザ上でビデオ/オーディオの通信や、データ通信を行うための規格です。HTML5で新しく策定されたもので、複数の技術の連携で成り立っています。 ちなみに策定には複数の団体が絡んでいています。

  • API → W3C
  • ビデオ/オーディオのコーデック → IETF

APIの策定作業はWebRTC 1.0に向けて大詰めに入っています。またより詳細な低レベルのAPIを定義しているORTCも登場し、将来の統合に向けた動きも始まっています。

コーデックの選定では、ブラウザがサポートしなければならないビデオコーデックに、既にデファクトスタンダードともいえるH.264が加わりました。また、Googleが開発しているオープンソースのビデオコーデック「VP9」をサポートするブラウザも増えています。

WebRTCで何ができるの?

WebRTCは厳密に言うとビデオ/オーディオ/データ通信を行うための仕組みですが、他にも関連が深い技術があります。この連載では3つをまとめてWebRTC(とその仲間たち)ということで扱います。

この3兄弟に、HTML5の様々な要素を組み合わせて活用することができます。

  • JavaScript(大前提)
  • videoタグ、audioタグ
  • CSS3
  • Canvas
  • WebGL
  • Web Audio API
  • WebSocket

新しいAPIでカメラを使ってみよう

カメラやマイクと言ったデバイスにアクセスするには、従来はnavigator.getUserMedia()というAPIを使いました。2016年6月現在では、これに代わる新しいAPIが登場しています。

  • navigator.mediaDevices.getUserMedia() になった
  • ベンダープレフィックスが取れた
  • コールバックではなくPromiseベースになった

またデスクトップブラウザの場合は次のブラウザで利用することができます。

  • Firefox 47 … 利用可能
  • Chrome 51 … 利用する場合にはフラグ設定が必要
    • chrome://flagsというURLを開く
    • 「試験運用版のウェブ プラットフォームの機能 #enable-experimental-web-platform-features」を有効に
    • Chromeを再起動
  • Edge 25 … Windows 10 のEdgeでも利用可能

サンプルコード

それではカメラから映像を取得してみましょう。サンプルコードはこちらです。

<!doctype html>
<html>
<head>
 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 <title>Camera with mediaDevice</title>
</head>
<body>
  <button onclick="startVideo()">Start</button>
  <br />
  <video id="local_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>
</body>
<script type="text/javascript">
  let localVideo = document.getElementById('local_video');
  let localStream;
  
  // start local video
  function startVideo() {
    navigator.mediaDevices.getUserMedia({video: true, audio: false})
    .then(function (stream) { // success
      localStream = stream;
      localVideo.src = window.URL.createObjectURL(localStream);
    }).catch(function (error) { // error
      console.error('mediaDevice.getUserMedia() error:', error);
      return;
    });
  }
</script>
</html>

このHTMLファイルに、(Webサーバ経由で)ブラウザからアクセスしてみてください。GitHub Pagesでも公開していますので、すぐに試すことができます。

  • GitHub Pages で試す camera_new.html(※Chromeの場合は、上記のフラグ設定が必要です)
  • GitHub でソースを見る camera_new.html

ブラウザで[Start]ボタンをクリックすると、カメラへのアクセスの許可を求めるダイアログが表示されますので、許可してください。
Firefoxの場合 getusermedia_ff

Chromeの場合 getusermedia_chrome

Edgeの場合
getusermedia_edge
ウィンドウの下の部分にこのように表示されます。他のブラウザと違うので、ちょっと気が付きにくいかもしれません。

すると、このようにカメラの映像が表示されるはずです。 camera_ff

HTMLファイルの置き場所

カメラやマイクにアクセスするには、サンプルのHTMLファイルをWebサーバーに配置する必要がありますが、実はブラウザによって条件が異なります。

  • Chromeの場合 … 原則 https://~ のみ。例外として http://localhost/~ でも利用可能
  • Firefoxの場合 … https://~ および http://~ の両方で利用可能。さらに file://~ でも利用可能
  • Edgeの場合 … https://~ および http://~ の両方で利用可能。さらに file://~ でも利用可能

FirefoxやEdgeの場合は、ローカルのHTMLファイルを直接読み込んでも、利用可能です。

トラブルシューティング

カメラのアクセスの許可を求められた際に「常に拒否」すると、そのサイトからはカメラの映像を取得することができなくなります。その場合は明示的に再許可してあげる必要があります。

Firefoxの場合getusermedia_ff_ngで「許可」または「毎回確認する」を選択

Chromeの場合getusermedia_chrome_ng「常に許可する」を選択

Edgeの場合
※いまのところ「常に拒否」することはできないようなので、このケースは発生しません。

新旧 getUserMedia() をラップしてみると

新しいAPIである navigator.mediaDevices.getUserMedia()は、まだChromeではデフォルトの状態では使えません。これは、引数で渡すオプションの指定方法が、まだ新しい書き方に沿っていないことが原因です。

let deviceID = getSelectedIDsomehow(); // デバイスIDを指定する

// Firefoxの場合
let constraints = {
  audio: false,
  video: { 
   deviceId: {exact: deviceID}
  }
};

// Chromeの場合
let constraintsForChrome = {
  audio: false,
  video: {
    optional: [{sourceId: deviceID}]
  }
};

そのため、Chromeではしばらく新しいAPIはデフォルトでは使えない状態が続くと思われます。そこで古いAPIをPromise型にラップする例を用意してみました。(オプション指定の違いは吸収できていません)

// --- prefix -----
  navigator.getUserMedia  = navigator.getUserMedia    || navigator.webkitGetUserMedia ||
                            navigator.mozGetUserMedia || navigator.msGetUserMedia;

  // ---- 新旧APIをPromiseでラップする ----
  function getDeviceStream(option) {
    if ('getUserMedia' in navigator.mediaDevices) {
      console.log('navigator.mediaDevices.getUserMadia');
      return navigator.mediaDevices.getUserMedia(option);
    }
    else {
      console.log('wrap navigator.getUserMadia with Promise');
      return new Promise(function(resolve, reject){    
        navigator.getUserMedia(option,
          resolve,
          reject
        );
      });      
    }
  }

  // 利用例
  function startVideo() {
    getDeviceStream({video: true, audio: false})
    .then(function (stream) { // success
      localStream = stream;
      localVideo.src = window.URL.createObjectURL(localStream);
    }).catch(function (error) { // error
      console.error('getUserMedia error:', error);
      return;
    });
  }

これを使ってカメラ映像を取得する例もご用意しました。Chrome 51でもデフォルトのままで利用できます。

様々なWebRTC関連のブラウザの違いを吸収する adapter.js本家のGitHubで公開されています。より便利に使いたい場合は、そちらをご利用ください。

CSS3と組み合わせてみよう

WebRTCは他の要素と組み合わせて使うことができると書きました。実際にCSS3と組み合わせてみましょう。

<!-- 通常 -->
  <video id="local_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>

  <!-- 左右反転 -->
  <video id="flip_video" autoplay style="transform: scaleX(-1); width: 320px; height: 240px; border: 1px solid black;"></video>

  <!-- 角丸 -->
  <video id="round_video" autoplay style="border-radius: 80px 80px 80px 80px; width: 320px; height: 240px; border: 1px solid black;"></video>

  <!-- セピア -->
  <video id="filter_video" autoplay style="filter: sepia(100%); -webkit-filter: sepia(100%); width: 320px; height: 240px; border: 1px solid black;"></video>

ほかにも様々なバリエーションが考えられます。ぜひ自分でもいろいろ試してみてください。

また、CSS3アニメーションを使うこともできます。例えばこんな感じです。

<!doctype html>
<html>
<head>
 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 <title>Camera with mediaDevice</title>
 <style type="text/css">
   #rotate_video
   {
    animation-duration:4s;
    animation-iteration-count:infinite;
    animation-timing-function:linear;
    animation-name:rotate360;     
   }

   #shake_video
   {
    animation-duration:0.5s;
    animation-iteration-count:infinite;
    animation-timing-function:ease-in-out;
    animation-name:shake;     
   }
   
   @keyframes rotate360
   {
    0%{transform:rotate(0deg);}
    100%{transform:rotate(360deg);}
   }

   @keyframes shake
   {
    0%{transform:rotate(-20deg);}
    50%{transform:rotate(20deg);}
    100%{transform:rotate(-20deg);}
   }
  </style> 
</head>
<body>
  Camera with mediaDevice.getUserMedia()<br />
  <button onclick="startVideo()">Start</button>
  <br />
  <!-- 通常 -->
  <video id="local_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>
  <br />

  <!-- 回転アニメーション -->
  <video id="rotate_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>

  <!-- 振動アニメーション -->
  <video id="shake_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>
  
</body>
<script type="text/javascript">
  // --- prefix -----
  navigator.getUserMedia  = navigator.getUserMedia    || navigator.webkitGetUserMedia ||
                            navigator.mozGetUserMedia || navigator.msGetUserMedia;

  let localVideo = document.getElementById('local_video');
  let localStream;
  
  // start local video
  function startVideo() {
    getDeviceStream({video: true, audio: false})
    .then(function (stream) { // success
      localStream = stream;
      localVideo.src = window.URL.createObjectURL(localStream);
	  
      document.getElementById('rotate_video').src = localVideo.src;
      document.getElementById('shake_video').src = localVideo.src;
    }).catch(function (error) { // error
      console.error('mediaDevice.getUserMedia() error:', error);
      return;
    });
  }

  // ---- 新旧APIをPromiseでラップする ----
  function getDeviceStream(option) {
    if ('getUserMedia' in navigator.mediaDevices) {
      console.log('navigator.mediaDevices.getUserMadia');
      return navigator.mediaDevices.getUserMedia(option);
    }
    else {
      console.log('wrap navigator.getUserMadia with Promise');
      return new Promise(function(resolve, reject){    
        navigator.getUserMedia(option,
          resolve,
          reject
        );
      });      
    }
  }
</script>
</html>

camera_css_animation

CSS3と組み合わせたサンプルも、github pagesでも公開していますので、試してみてください。(ラップ関数を使っているので、デフォルトのChrome 51でも利用できます)

次回は

今回は新しいAPIを使って、カメラの映像を取得してみました。次回はWebRTCによる通信を手動で接続してみます。

]]>
https://html5experts.jp/mganeko/19728/feed/ 0