Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

どうも。WACULでフロントエンジニアをしている @Quramy です。
このエントリはWACUL Advent Calendar 2016の8日目の記事として書いています。

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

rxdb_trending.png

RxDBの概要

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

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

Reactive

また、RxDBの特徴として、データベースのクエリをRxJSの Observable として扱えることが挙げられます。

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

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

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

RxDBの使い方

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

データストアへの接続

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

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

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
RxDB.plugin(require("pouchdb-adapter-memory"));
RxDB.create("myDb", "memory").then(db => { ... });
  • CauchDBと互換性のあるDBとHTTPで接続
RxDB.plugin(require("pouchdb-adapter-http"));
RxDB.create("http://localhost:5000/my-db", "http").then(db => { ... });

プラグイン名に pouchdb とあるからも分かるように、これらはPouchDBのプラグインです。
RxDBはPouchDBのラッパーであるため、PouchDBが接続可能なデータストアが利用可能です。

Adapters.png
https://pouchdb.com/adapters.html より

Collection, Documentの操作

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

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ですね。

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 を返すようになります。

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 Databaseon と似ていますね。

この他、Document オブジェクトには remove, set, saveといった一通りの操作が揃っています。詳細はAPIドキュメントを参照してください。

Synchronize

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

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");
});

RxDBAPIドキュメントに記載はありませんが、 pouchdb-replication というプラグインを事前に読み込ませておく必要があります。

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

まとめ

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

おまけ

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

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-hostinghttps://cloudant.com といった選択肢があるようです。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした