RxDB それはReactiveでOffline-firstなデータベース

どうも。WACULでフロントエンジニアをしている @Quramy です。
このエントリは[WACUL Advent Calendar 2016](http://qiita.com/advent-calendar/2016/wacul)の8日目の記事として書いています。

さて、RxDBというデータベースをご存知でしょうか？
僕もつい先日知ったばかりなのですが、2016年12月になって、急激にGitHubのtrend入りしてきたプロダクトです。

<img alt="rxdb_trending.png" src="https://qiita-image-store.s3.amazonaws.com/0/8808/b3351d88-b1d1-6562-6942-867181267f9d.png">


## RxDBの概要
[RxDB](https://github.com/pubkey/rxdb)は以下の機能をもった、主にフロントエンド向けのデータベースです。

> * Reactive (rxjs)
> * Replication / Sync
> * Schemas (jsonschema)
> * Mango-Query (MongoDB)
> * Encryption
> * Level-Adapters
> * Import/Export (.json)
> * MultiWindow-Support

データベースといってもRxDB自体が決まったデータストアを持っている訳ではなく、IndexedDB or websql, Local Storageやsqlite, HTTP接続したCouchDB等、アプリケーションの動作環境に合わせてデータの保存先を選択するイメージです。この意味ではデータベースのクライアント、という説明の方が正しいかもしれません。

### offline-first
このエントリのタイトルにも含めた通り、RxDBはoffline-firstで利用することを前提としたデータベースです。
![offline-first.png](https://qiita-image-store.s3.amazonaws.com/0/8808/2f4beba6-9c6b-b5bc-1358-6481ce3cd59c.png)

例えば、Evernoteのようにユーザーのデータを複数のデバイス間で連携するようなアプリケーションを想像してみてください。
RxDBはこういったアプリケーションの構築に向いています。図のように、デスクトップPCブラウザではIndexedDBに、モバイルアプリではsqliteに、それぞれ端末のデータを保存しておき、online時にリモートに存在しているデータストアと同期することができます。

### Reactive
また、RxDBの特徴として、データベースのクエリを[RxJS](https://github.com/ReactiveX/rxjs)の Observable として扱えることが挙げられます。

```js
const messages$ = collection.find({}).$.map(docs => docs.map(doc => doc.get("message")));
messages$.subscribe(messages => console.log(messages));
```

上記コード中のObservable変数 `messages$` にはRxDBのコレクションが変化する度に値が流れます。

ユーザーの操作によりアプリケーションからコレクションが変更された場合、リモートデータストアとの同期によって、ローカルのRxDBに変更が発生した場合の双方で `messages$` に値が流れてくるため、アプリケーション開発者は流れてきたクエリ結果の処理に注力することができます。特にReactやAngularのように、宣言的にViewを組み立てることの出来るフレームワークと組み合わせると強いですね。


## Demonstration
折角ですので、RxDBとReactJSを使った簡単なチャットアプリを作ってみました。
![4hHCrStMKO.gif](https://qiita-image-store.s3.amazonaws.com/0/8808/1270e2d0-6048-d729-3244-d6ecd4122c96.gif)


1. Chrome(図中左) と Electron(図中右)、それぞれのLocal Storageにデータを保存
1. online中は、リモートデータストア(といってもlocalhostですが...)と同期[^1]
1. データストア変更ストリームを購読して画面を更新
1. リモートデータストアを落しても、Local Storageにデータが保存されている
1. リモートデータストアを復帰してonlineに戻すと、offline中に行ったデータ変更が再びリモートデータストアへ反映される

実際、明示的に開発したのは 3. の部分だけで、それ以外の処理は全てRxDBが勝手にやってくれます。
エントリの末尾にデモアプリのソースコードを貼ったので、興味がある方は読んでみてください。デモアプリがとても簡単に作れることがわかるかと思います。

## RxDBの使い方
ここからはRxDBの使い方を簡単に説明していきます。RxDBはNPMから `npm i rxdb` でインストールできます。


### データストアへの接続
先述したようにRxDBは様々なデータソースをデータストアとして利用することができます。

例えば、ブラウザのLocal Storageを利用する場合、下記のように記述します。

```js
const RxDB = require("rxdb");

RxDB.plugin(require("rxdb-adapter-localstorage"));
RxDB.create("myDb", "localstorage").then(db => {
  // use db...
});
```

`npm i rxdb-adapter-localstorage` を実行して、プラグインを追加でインストールしてください。

`require`を利用していますので、ブラウザで動作させる場合にはbrowserifyやwebpackを利用してbundleを作成するとよいです。

この他にも、例えば下記のpluginが利用できます。

* ブラウザ上オンメモリDB

```js
RxDB.plugin(require("pouchdb-adapter-memory"));
RxDB.create("myDb", "memory").then(db => { ... });
```

* CauchDBと互換性のあるDBとHTTPで接続

```js
RxDB.plugin(require("pouchdb-adapter-http"));
RxDB.create("http://localhost:5000/my-db", "http").then(db => { ... });
```

プラグイン名に `pouchdb` とあるからも分かるように、これらは[PouchDB](https://pouchdb.com/adapters.html)のプラグインです。
RxDBはPouchDBのラッパーであるため、PouchDBが接続可能なデータストアが利用可能です。

![Adapters.png](https://qiita-image-store.s3.amazonaws.com/0/8808/09301c6b-8e3b-03e3-b627-279d1c62e9e4.png)
*[https://pouchdb.com/adapters.html](https://pouchdb.com/adapters.html) より*


### Collection, Documentの操作
DBのインスタンスにCollection名とSchemaを食わせると、Collectionを取得できます。

```js
const mySchema = {
    title: "my first schema",
    type: "object",
    properties: {
      id: {type: string, primary: true},
      name: {type: string},
      age: {type: number}
    }
};
const myCollection = await db.collection("myCollection", mySchema);

await myCollection.insert({id: "001", name: "Quramy", age: 32});
```

CollectionはDocumentを取得することができます。MongoDB / mongooseに触れたことがある方であれば、馴染みやすいAPIですね。

```js
const docs = await myCollection.find({}).exec();
const docs = await myCollection.find().where("age").lt("20").sort("-age").exec();
const doc = await myCollection.findOne({name: {$eq: "Quramy"}}).exec();
```

`exec` はPromiseを返すAPIですが、代わりに `.$` とすることで Observable を返すようになります。

```js
const docs$ = await myCollection.find().where("name").eq("Quramy").sort("-age").$;
docs$.map(docs => docs.map(doc => doc.get("name"))).subscribe(names => console.log(names));
```

`.$` で取得したObservableにはクエリの取得結果に変更があると、その都度データが流れてきます。
[Firebase Realtime Database](https://firebase.google.com/docs/reference/js/firebase.database.Query?hl=ja#on) の `on` と似ていますね。

この他、Document オブジェクトには `remove`, `set`, `save`といった一通りの操作が揃っています。詳細は[APIドキュメント](https://github.com/pubkey/rxdb/blob/master/docs/RxDocument.md)を参照してください。

### Synchronize
Collectionに用意されている `sync` メソッドを利用することで、対象のCollectionを別のデータストアと同期させます。

```js
RxDB.plugin(require("rxdb-adapter-localstorage"));
RxDB.plugin(require("pouchdb-adapter-http"));
RxDB.plugin(require("pouchdb-replication"));
RxDB.create("myDb", "localstorage").then(db => {
  return db.collection("myCollection", mySchema);
}).then(myCollection => {
  return myCollection.sync("http://localhost:5000/my-db");
});
```

RxDB[APIドキュメント](https://github.com/pubkey/rxdb/blob/master/docs/RxCollection.md#sync)に記載はありませんが、 `pouchdb-replication` というプラグインを事前に読み込ませておく必要があります。

データストア同士のReplicationは[PouchDBが用意している機能](https://pouchdb.com/api.html#sync)であり、RxDBも内部的にはこれを利用しています。
PouchDBの `sync` メソッドでは、同期完了後にアプリケーションで最新のデータを扱うためには、適切に`change` イベントをハンドリングする必要がありますが、RxDBはこのような処理を肩代わりしてくれます。
同期実行後にローカルデータストアの変更を検知した場合、最新のクエリをObservableへと流し込んでくれるのです。


## まとめ
このエントリでは、RxDBの概要とその使い方をかいつまんで説明しました。
まだ若いプロダクトであるため、今後どのように発展していくかは未知数ですが、クライアントバックエンドにデータストアが必要となった際には選択肢の1つとして検討してみては如何でしょうか。


## おまけ
本文中のデモ用に作成したアプリケーションのコードを貼っておきます。
また、レポジトリは [Quramy/rxdb-simple-chat-app](https://github.com/Quramy/rxdb-simple-chat-app) となります。実際の動作を確認したい場合はこちらから。

<!-- {{{ -->
```js
import "babel-polyfill";

import * as RxDB from "rxdb";
import { mySchema } from "./my-schema";

import React from "react";
import injectTapEventPlugin from "react-tap-event-plugin";
import ReactDOM from "react-dom";

import MuiThemeProvider from "material-ui/styles/MuiThemeProvider";
import darkBaseTheme from "material-ui/styles/baseThemes/darkBaseTheme";
import getMuiTheme from "material-ui/styles/getMuiTheme";
import AppBar from "material-ui/AppBar";
import TextField from "material-ui/TextField";
import { Card, CardHeader, CardTitle, CardText, CardActions } from "material-ui/Card";
import IconButton from "material-ui/IconButton";
import RaisedButton from "material-ui/RaisedButton";
import Delete from "material-ui/svg-icons/action/delete";
import Snackbar from "material-ui/Snackbar";

injectTapEventPlugin();

export class RxDbChat extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      newMessage: "", messages: [], syncState: null
    };
  }

  async componentDidMount() {
    const myCollection = await db.collection("myCollection", mySchema);
    myCollection.query().sort({id: 1}).$.filter(docs => !!docs).map(docs => docs.map(doc => (
      {id: doc.get("id"), message: doc.get("message")}
    ))).subscribe(messages => {
      this.setState({messages: messages.reverse()});
    });
    this.myCollection = myCollection;
    this.myCollection.sync("http://localhost:5000/my-db");
  }

  async handleOnSubmit(e) {
   e && e.preventDefault();
   const id = Date.now() + "";
   const newMessage = {id, message: this.state.newMessage};
   await this.myCollection.insert(newMessage);
   this.setState({newMessage: ""});
  }

  async handleOnClickDetele(id) {
    const doc = await this.myCollection.findOne(id).exec();
    if (!doc) return;
    await doc.remove();
    this.setState({messages: this.state.messages.filter(m => m.id !==id)});
  }

  handleOnChangeNewMessage(e) {
    this.setState({newMessage: e.target.value});
  }

  renderMessages() {
    const {messages} = this.state;
    return messages.map(({id, message}) => {
      const date = new Date(+id).toLocaleString();
      return (
        <Card key={id} style={{marginBottom: 20}}>
          <CardHeader title={date} />
          <CardText>{message}</CardText>
          <CardActions>
            <IconButton onClick={this.handleOnClickDetele.bind(this, id)}>
              <Delete />
            </IconButton>
          </CardActions>
        </Card>
      );
    });
  }

  render() {
    return (
      <div>
        <AppBar title="RxDB Chat" />
        <div style={{padding: 30}}>
          <form style={{marginBottom: 20}} onSubmit={this.handleOnSubmit.bind(this)}>
            <TextField
              fullWidth={true}
              hintText="Hit enter to post"
              floatingLabelText="Message"
              value={this.state.newMessage}
              onChange={this.handleOnChangeNewMessage.bind(this)}
            />
          </form>
          <div>{this.renderMessages()}</div>
        </div>
      </div>
    );
  }
}

const App = () => {
  if (!window.process) {
    return (<MuiThemeProvider><RxDbChat /></MuiThemeProvider>);
  } else {
    // for Electron
    document.documentElement.style.backgroundColor = "#303030";
    return (<MuiThemeProvider muiTheme={getMuiTheme(darkBaseTheme)}><RxDbChat /></MuiThemeProvider>);
  }
};

let db;

RxDB.plugin(require("rxdb-adapter-localstorage"));
RxDB.plugin(require("pouchdb-adapter-http"));
RxDB.plugin(require("pouchdb-replication"));
RxDB.create("myDb", "localstorage").then(_db => {
  db = _db;
  console.log(db);
  ReactDOM.render(<App />, document.getElementById("app"));
});

```

<!-- }}} -->

## 脚注・参考リンク
[^1]: リモートデータストアには、PouchDB Serverを使いました。クラウドホスティングで試すのであれば、 https://www.smileupps.com/free-couchdb-hosting や https://cloudant.com といった選択肢があるようです。