xor

二兎を得るか、一兎をも得ざるか

ローカルでも動くハイブリッドmBaaS的なグラフDBライブラリ "gun.js" で分散型WebAppを作る【基礎編】

研究関係で「gun.js」というものを使うことになったのですが、触ってみると面白くて気に入りました。日本語情報があまりないので、利用してみてわかったことをできるだけ書いてみます。

https://gun.eco/gun.eco

どういうライブラリ?

ざっくり言うと俺だけのFirebaseという感じです。
その割には複雑な設定やアカウント管理がいらず、ライブラリをインクルードしてスクリプト内でインスタンス生成するだけでデータの出し入れができてしまう、というシンプルな仕組みになっています。

(追記) Firebaseライクというより「Cloud firestore」「Realtime database」の各NoSQLなデータベースに似ているという感じ、です。Firebase自体には他にも様々な機能がありますので、gunがFirebaseの代替となり得るわけではありません。表記が雑すぎて申し訳ないです(あとFirebaseアンチではないです、念のため)。

  • 「ローカル(オフライン端末)を第一に」を理念に設計されており、バックエンドを用意しなくても単体のDBとして利用できます。
    あとから必要になった段階で、HerokuやローカルでのNode.jsといった様々な手段で提供されるバックエンドサーバーを追加でき、そのサーバーを経由して多数の端末のデータをネットワーク状況に応じて柔軟に同期してくれます。

  • EventEmitterを備えたメソッドチェーンを持ち、ネットワーク内でデータが変化するとイベントが発火しコールバックを呼ぶことができます。ReactやVueなどを使わなくとも、データドリブンなWebアプリケーションを作成することができます。

  • ユーザー認証機能も備えており、個別のユーザーが登録したデータは公開鍵により暗号化され、データが分散化されても安全性を保てるような仕組みとなっているようです。(まだ試せてない)

グラフDBとは

jp.drinet.co.jp

数学における「グラフ」(グラフ理論)は、点で示される「ノード」と、その関係性の接続を示す線である「エッジ」で構成されます。
gun.jsにおけるデータ構造は基本的には単一方向性グラフです。

f:id:ukkz:20200906143308p:plain

  • get メソッドチェーンによって、グラフ構造のデータをノード名を指定して辿ることができます。
  • put メソッドによってデータ(数値・文字列・null・オブジェクト)をノードに設定することができます。
    データがオブジェクトの場合、各要素はそのキー名のノードに変換されます。
  • set メソッドによって、ノード間をエッジで接続することができます。
    配列変数はノードに設定できないかわりに、単一の親ノードに対してsetを使い複数の子ノードとの関係性を示すことで記録することができます。

1度も get を行っていないgunオブジェクトは「Root Node」ですがグラフの頂点というわけではなく、そこから1回だけ get を行ったノードがグラフ構造としての最上位階層となるようです。
MySQLに置き換えると「Root Node」はDBMSにあたり、1回の get でノードを指定して第2階層目に降りることがDBの指定、というふうに考えれば良さそうです。実際のデータ構造は第3階層目から、ということですね。

また get は、指定したキーが存在しない場合は暗黙的に作成して階層を降りていくため、データベースとしての冪等性はあるもののプログラム上の副作用が存在します。
データバインディングを検出するライブラリを併用する際は注意が必要です。

何が分散型(Decentralize)なのか

まず「グラフDBであること」と「分散型システムであること」は全く別の話になります。

f:id:ukkz:20200906143248p:plain

  • このgun.jsはサーバーサイド(Node.js)でもクライアントサイド(ブラウザ)でも動作し、複数のホスト同士がネットワークで繋がることを必要とせず単一の端末上で動作します。
    この場合のデータの保存先は、クライアントサイドでは localStorage 、サーバーサイドでは Radisk Storage Engine というものが使われているようです。
    オプションで指定することによってJSONファイルやAmazon S3に保存ができるほか、将来的にはIPFSに対応する予定のようです。
    参考: https://gun.eco/docs/Storage

  • クライアントサイドのコンストラクタまたはオプションメソッドにて、

    • グローバルにアクセス可能なホスト上のNode.jsで実行されている
    • HTTPリクエストが適切に Gun.serve() によってハンドリングされるようになっている

    上記2つを満たしたホストのエンドポイントURLを指定することにより、そのホストとクライアントのデータの同期が開始されます。
    複数のクライアントが特定のホストを頂点とするツリー構造を形成している場合、データの変化はそのネットワーク全体に伝搬します。

以上の通り、
基本的にローカルで完結するグラフDBであるが、公開されているホストを経由してネットワーク内の他の端末とデータを同期、複数の異なる端末で同一のデータを保存できる点で「分散型システムである」とされています。

また、端末がオフライン状態であってもデータの変更はローカルに保存され、オンラインになったときに自動で同期されるほか、データのコンフリクトがあった際にもマージするアルゴリズムを設定することができるようです。

ミニマルにためしてみる

流れの都合上、サーバー側 → クライアント側 で書きますが、プロトタイプ段階ではクライアント側構成のみで大丈夫です。サーバーレスなので(ここでドヤ顔)

サーバー側(GunDB node)

サーバー側を準備することでgunの真価が発揮されます。が、プロトタイプ段階で用意する必要がなく、後付けでも問題なく同期されるのですっ飛ばして構いません。

Heroku一発ボタンで

elements.heroku.com

「Deploy to Heroku」ボタンにより、すぐに自分専用の一時的なサーバーをたてることができます。(おすすめ)
Firebaseが障害なんてほぼ無さそうですが、そういったベンダー依存のトラブルとはもうおさらばです。

ローカルPC上 / オンプレ / VPSなどで

まずはLTS版のNode.js(現在v12系)をインストールします。
僕個人の環境ではCentOS8に導入したため、公式で案内されているnodesourceからcurlしてシェルを回す方法でインストールしました。
Windowsではインストーラから、Macはnodebrewからいれるのが最適です。

github.com

$ curl -sL https://rpm.nodesource.com/setup_lts.x | sudo bash -
$ sudo dnf install -y nodejs
-- (中略) --
$ node -v
v12.18.3
$ npm -v
6.14.6

次に適当なディレクトリを作ってnpmでgunをインストールします。

$ sudo mkdir /var/web/gun
$ cd /var/web/gun
$ sudo chown -R me:nginx ./
$ npm install gun
-- (中略) --
+ gun@0.2020.520
added 21 packages from 36 contributors and audited 21 packages in 1.974s
found 0 vulnerabilities

main.js を作成し、次のように書き込みます。
httpsモジュールを利用しつつ中身がWebSocketサーバーになっている構成です。
ローカル環境以外ではhttpモジュールでよいですが、それ以外は必ずSSLを利用してください。以下ではLet's Encryptを利用して取得した証明書ファイルを利用してWSSサーバーを立てています。

もちろんNode.jsでSSLを使わずに、nginx側でHTTPS接続からリバースプロキシでこちらに飛ばすようにする構成も可能です。

const wss_port      = 9999;
const serve_path    = '/var/web/gun';
const ssl_key_path  = '/etc/letsencrypt/live/your-domain.com/privkey.pem';
const ssl_cert_path = '/etc/letsencrypt/live/your-domain.com/fullchain.pem';

const fs    = require('fs');
const https = require('https');
const Gun   = require('gun');

const config = {
    port: wss_port,
    key:  fs.readFileSync(ssl_key_path),
    cert: fs.readFileSync(ssl_cert_path),
};
config.server = https.createServer(config, Gun.serve(serve_path));

const gun = Gun({ web: config.server.listen(config.port) });
console.log('Relay peer started on port ' + config.port + ' with /gun');

実行するとこんな感じで表示が出て、ここの例だと9999番ポートでWebからのクライアントの接続を待ち受けてくれます。

$ node main.js
Hello wonderful person! :) Thanks for using GUN, please ask for help on http://chat.gun.eco if anything takes you longer than 5min to figure out!
AXE enabled.
Multicast on 233.255.255.255:8765

マルチキャストの表示が出ますがこれは気にしなくてよいです。(require元を変更することで回避はできるもよう)
ローカルサーバーでない場合はポートを開放またはリバースプロキシで転送をしておきましょう。

このサーバーはクライアント側にてURLを指定して実際に利用しますが、その際はポート番号と /gun エンドポイントをつけて以下のように指定します。
https://server1.com:9999/gun

スキーマは、gun.js内で http は自動的に ws に変換されるのであまり気にしなくてよいです。

クライアント側(dApp)

クライアント側と言ってもWebアプリそのものです。
先述の通りgun自体はサーバーが無くても動作しますので。Local Storageを使って永続化するデータを利用するプロトタイプを作るぐらいなら、絶対にgunを使ったほうが良いです。
サーバーが後から有効化されると、分散型アプリケーション(Decentralized Application = dApp)として利用することができます。
ただしブロックチェーンにおけるdAppとは文脈が違う(もちろん合意形成なんてありません)ので気をつけてください。


今回はさくっとNuxt.jsベースで作成します。 最初にgunのライブラリを読み込むため、 nuxt.config.js 内のhead部分を以下のように書きます。

head: {
  script: [
    { src: 'https://cdn.jsdelivr.net/npm/gun/gun.js' }
  ],
}

最も簡単な扱い方としては、dataで直接インスタンスを作ってしまうことです。

<script>
export default {
  data: () => ({
    gun: Gun(),
  }),
}
</script>

後に紹介するサーバー側のノードを作成していれば、以下のようにするのがdAppっぽいです。

<script>
export default {
  data: () => ({
    gun: null,
    gunNodes: [
      'https://server1.com/gun',
      'https://server2.com/gun',
    ],
  }),
  created: function() {
    this.gun = Gun(this.gunNodes);
  }
}
</script>

このWebアプリ側の詳細は別記事で書こうと思います。