Posted at

ブロックチェーンのP2P通信にWebRTCを使う

この記事はBlockChain Advent Calendar 2018 - Qiitaの3日目です。

さて、ブロックチェーンは主にP2P通信環境で使われる技術です。中央集権的なサーバーとクライアントという非対称ではなく、ピア同士がつながるというネットワーク構造です。たとえばBitcoinであればTCPで接続してバイナリデータをやりとりします。

P2Pというと、WebRTCという技術があります。イマドキのウェブブラウザに標準的に載ってる機能であり、標準プロトコルです。ブラウザ以外でも実装されていて、様々な環境でWebRTCを使うことができます。

この記事はWebRTCを使ってブロックチェーンアプリを作ってみたいなーと思って書きました。本記事のサンプルコードはTypeScriptです。(型情報を削ればJavaScriptでそのまま動きます)


WebRTC

WebRTCはUDP上で暗号化されたデータ通信を行うP2Pプロトコルです。QUICと似たような仕組みで、TCP的な信頼性を持ちます。オプションで単純なUDPレベルの信頼性まで落とすことも可能です。

もしかしたらWebRTCというと映像や音声のストリーミングのイメージがあるかもしれませんが、データチャネルという任意のデータを送受信できる機能もあります。


WebRTCを使いたいモチベーション

ウェブブラウザでは PWA の技術を使えば、モバイルやPCのローカルなアプリとして動かすことができ、ウェブブラウザというプラットフォームだけを間借りすることができます。ブロックチェーンに直接つながるアプリの作成方法に選択肢が1つ増えるということです。

また、WebRTCはP2P接続のめんどくさい部分を引き受けてくれるので楽ができるというのもあります。


WebRTCで接続する

WebRTCで接続するためにはSDP(Session Description Protocol)という形式のテキストを、「何かしらの方法」でお互いに交換する必要があります。

a=group:BUNDLE data

a=msid-semantic: WMS
m=application 51065 DTLS/SCTP 5000
c=IN IP4 192.168....
b=AS:30
a=candidate:3601362944 1 udp 2122129151 192.168.... 51065 typ host generation 0 network-id 1 network-cost 50
a=candidate:3782639666 1 udp 2122063615 192.168.... 63031 typ host generation 0 network-id 3 network-cost 50
a=ice-ufrag:zOZB

SDPはこのように、一文字のアルファベット=なんとか、みたいな形式のデータで、接続に必要な情報が入っています。

「何かしらの方法」という思わせぶりな言葉が先ほどの説明にありましたが、このSDPを交換する手順がWebRTCでは規定されていないためです。あらゆる手段を使ってでもやりとりできれば良いという考え方です。

いったんここではSDPのやりとりの方法は、後回しにします。

これからWebRTCで接続する流れを見ていきましょう。

const connect = async () => {

// 1. RTCPeerConnection を作成する
const peer = new RTCPeerConnection(rtcopt)

// 2. データチャネルの設定をする
const dataChannel = peer.createDataChannel('webrtc-coin')

// 3. SDPというコネクション情報(オファー)を作成して相手に伝える
const offer = await peer.createOffer()
await peer.setLocalDescription(offer)
await waitVannilaIce(peer)

dataChannel.onopen = () => {
// 接続してデータのやりとりができるようになったら呼び出される
dataChannel.send('Hello, WebRTC P2P!')
}

// 4. 相手側のSDP(アンサー)を受け取って登録する
const answer = await handshake(peer.localDescription)
await peer.setRemoteDescription(answer)
}

いくつかこの関数の中で定義されていないものが登場します。

4番に出てくる handshake は「何かしらの方法」で相手に自分のSDP(offer)を送りつけて、相手のSDP(answer)を取得する関数です。

1番のrtcoptと2番のwaitVannilaIceはP2P通信のために必要なオプションと関数です。

const rtcopt: RTCConfiguration = {

iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
iceTransportPolicy: 'all'
}

WebRTCではP2P通信を何が何でも成功させるために、お互いの情報を交換して最適な方法をみつけます。これをICE(Interactive Connectivity Establishment)といいます。NAT越えに必要な情報などもインタラクティブに開いてとやりとりするのです。

iceServersというのはP2Pなのにサーバーを経由するの?という疑問もあるかもしれませんが、グローバルアドレスの検出を行ってくれるだけでP2P通信には関与しません。このサーバーをSTUNサーバーといいます。

じつは通信を中継するTURNサーバーというのもあります。これになるとP2Pとはって感じになりますが、まぁどうやってでも接続するときには最終手段として用いられます。

P2P通信ではLAN内の端末につきものの、グローバルアドレスの検出やNAT越えなどを解決するために、ICE serverというものを経由することができます。ICE serverはグローバルアドレスを教えてくれたり暗号化されたデータをリレーしてくれたりする存在です。

次の waitVanillaIce は、ICEの手順を簡略化させる(Vannila ICE 化)ための関数です。

const waitVannilaIce = (peer: RTCPeerConnection) => {

return new Promise(resolve => {
peer.onicecandidate = ev => {
if (!ev.candidate) {
resolve()
}
}
})
}

引数の peer: RTCPeerConnection は TypeScript(Flowも同様)の型指定です。JavaScriptで書くならコロンから後ろの型情報を消します。

自分が出せる接続情報の候補を、一気に全部だしてから接続するというやり方を Vannila ICE と呼びます。利点は一回のSDPを交換するだけで接続が完了するので楽ちんなことです。

インタラクティブに接続情報の候補を都度相手に送りつけるやり方は、Trickle ICE と呼びます。面倒ですが Vanilla ICE よりも速く接続できます。

WebRTC の JavaScript でのコードでは、Vannila ICE か Trickle ICE かをフラグで制御できるわけではなくて、onicecandidateというイベントが発生するごとに、その情報を相手に送りつけるなら Trickle ICE になり、そうじゃなくて全部終わる if (!ev.candidate) まで待てば Vannila ICE になります。


handshake

今回 handshake には直接もう1つのピアを記述します。同じプロセスで2つのピアを立ち上げて通信(localhostの適当なポートでやりとり)します。

const handshake = async (offer: RTCSessionDescription) => {

// 1. RTCPeerConnection を作成する
const peer = new RTCPeerConnection(rtcopt)

// 2. 相手から受け取ったオファーを登録する
await peer.setRemoteDescription(offer)

// 3. アンサーを作成する
const answer = await peer.createAnswer()
await peer.setLocalDescription(answer)
await waitVannilaIce(peer)
console.log(peer.localDescription.sdp)

peer.ondatachannel = event => {
const dc = event.channel
dc.onmessage = ev => {
console.log(`message from peer: [${ev.data}]`)
}
}

return peer.localDescription
}


おさらい

RTCPeerConnectionを作成して、自分のSDPであるオファーを相手に送り、相手はアンサーというSDPを返します。SDPを交換することで接続情報を得て、P2P接続が完了します。


SDPのやりとりをどうするのか?

接続が完了したあとはもう普通にP2P通信ですがSDPの交換だけはなんとかしなければなりません。これは、ネットワークをどういうトポロジーで設計するのか、どういう通信をP2P上でやりとりするかなどを含めて、自由に設計できるところではあります。

考えられるのは、SDPの交換をHTTPSやWebsocketのサーバー上で行うというものです。

たとえば、ウェブアプリ(ウェブページ)を配信している側が、サーバーとしての役割も果たすというパターンが考えられます。これは接続情報のためにはサーバーを経由しなければいけないという問題はありますが、純粋なP2Pに限界を感じてハイブリッドにしたい、何かのアプリケーションに組み込みつつサーバーとの通信コストを下げる、などの方向性はありなのではないでしょうか?(というか本来のWebRTCはそれが想定されている)

Bitcoinのモバイルウォレットのように、ウェブアプリ版では信頼性を一部諦めるというな考え方もありでしょう。全世界のノードが参加してるときにウェブアプリ版だけで接続するとは限らないのであれば、比率的にはそんなに多くはないと見越して、ウェブアプリ版以外のノード(かつグローバルIPで受けられる環境)ではHTTPS/Webcoketなどのポートを空けておくのです。

SDP以前に、ICE server(STUN/TURN)にしても、GoogleのSTUNサーバーを使ってもおそらく問題は無いけど、P2Pの思想的にどうか?という場合は、STUNサーバーをさらにアプリに内蔵してしまうというような方法も考えられます。

他にも何かしらいい方法があるかもしれません。もし何か思いついたら、アドベントカレンダーの開いてるマスを埋めたいと思います。


おまけ(Node.js上で動かす)

npmパッケージでwrtcというのがメジャーっぽいので、それを使うといいでしょう。

型定義ファイルがないので面倒ですが、

;(global as any).RTCPeerConnection = require('wrtc').RTCPeerConnection

こういうようにする方法があります。これならVSCodeにも怒られずに普通に使えます。


サンプルコード

gistに、ブラウザで動くサンプルコードを置いています。Chrome 70.0.3538.110 で動作を確認しています。