[Electron] NeDBでデータを管理する

Node.jsで組み込み型データベースと言えばNeDBなわけですよ。100% JavaScriptで書かれておりMongoDBと同じ手軽なAPIで操作できる上になんと言っても超高速。先日は仕事で80万件ほどつっこんでみましたが普通に動いてビビリましたw

今回はそんなNeDBをElectronで利用する方法をまとめていきます。

ElectronでNeDBを利用する

準備

プロジェクト用のフォルダを作成しnpm initでpackage.jsonを用意、npm installで必要なモジュールをインストールします。

$ mkdir sample; cd sample
$ npm init
$ npm install -D electron electron-builder   
$ npm install nedb

アプリ内に埋め込む場合

編集や更新をしないデータの場合、ビルド時にデータファイルをアプリ内に詰め込んで利用することができます。

package.jsonに追記

electron-builderを利用している場合、package.jsonのbuildの項目にデータファイルを追記します。ここでのポイントは asarUnpack にも記述する必要がある点です。

Electronはビルド時にパフォーマンスアップのためファイルを1つにまとめてくれるのですが、NeDBのデータファイルがまとめられてしまうと、JavaScript上からうまいこと参照できなくなります。そこでまとめないで欲しいファイルをここに書いておくというわけです。

{
  "build": {
    "appId": "net.makitokatsube.blog.app.nedb",
    "files": [
      "package.json",
      "src/",
      "public/",
      "data/example.nedb"
    ],
    "asarUnpack": [
      "data/example.nedb"
    ],
}

メインプロセスから利用する

Node.jsで動かす時と同様にコードが書けますが、ファイルをロードする際にパスの処理を行います。

前述のpackage.jsonの設定を行っていると、NeDBのデータファイルは app.asar.unpackedフォルダの下に保存されています。しかしスクリプトapp.asarフォルダにいるため呼び出し時に以下の例のようにreplace()などで調整してやる必要があります。

const Datastore = require('nedb')
const path = require('path')

// パスを準備
const file = path.join(__dirname, 'data/example.nedb')
                      .replace('app.asar', 'app.asar.unpacked')

// NeDBを開く
const db = new Datastore({filename:file, autoload:true})
db
  .find({})
  .sort({id:1})
  .exec((err, doc)=>{
    console.log(doc)
  })

ビルド前にnpm startで実行した場合はasarでまとめられることはないため、通常replace()は空振りし、期待通りに実行されます。

注意点

この方法でデータの更新を行った場合、アプリをアップデートする際に上書きされ消えて無くなります。もし編集を行う可能性がある場合はアプリに保存する必要があります。

アプリ外に保存する場合

アプリ内にNeDBのデータファイルを詰め込んだ状態で編集してしまうと、アップデート時に消えて無くなってしまいます。そこでアプリの外側、ユーザーデータを保存するOSの領域に置いてやります。例えばデータファイルだけダウンロードして最新のものに更新したいといった場合はこの方法ですね。

メインプロセスから利用する

好きなタイミングでNeDBを利用できますが、ポイントはデータファイルの保存先をapp.getPath('userData')で返されるフォルダの下にする点です。

const { app, BrowserWindow } = require('electron')
const path = require('path')
const Datastore = require('nedb')

app.whenReady().then( ()=>{
  const file = path.join(app.getPath('userData'), 'launchlog.nedb')
  const db = new Datastore({filename:file, autoload:true})

  // データを挿入
  db.insert({time:new Date().getTime()})
})

データの保存場所

具体的なapp.getPath('userData')の戻り値は以下の通りです。

Windows
C:\Users(ユーザー名)\AppData\Roaming(アプリ名)
macOS
/Users/(ユーザー名)/Library/Application Support/(アプリ名)

npm startなどで実行後に実際にファイルを開いてみると無事に記録されているのがわかります。

$ cat '/Users/katsube/Library/Application Support/electron-sample-nedb/launchlog.nedb'
{"time":1616667037451,"_id":"RmE9lx7Kmay0eDru"}

サンプル「AppStore売上げランキング」

2021年3月22日時点のAppStoreの売上げランキングを表示するサンプルです。

アプリ内に埋め込んだNeDBのデータファイルを表示しつつ、起動する度に現在時間をローカルのNeDBファイルへ保存します。

最終的なファイル

実際のコードをGitHubにアップしています。必要に応じてご利用ください。 github.com

ソースコード

メインプロセス - src/index.js

/**
 * メインプロセス
 *
 * @author M.Katsube <katsubemakito@gmail.com>
 */
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const Datastore = require('nedb')
const ranking = require('./ranking')

function createWindow (file='index.html') {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, './preload.js')
    }
  })

  win.loadFile(`public/${file}`)
}

app.whenReady().then( ()=>{
  createWindow()

  // 起動ログを保存
  const file = path.join(app.getPath('userData'), 'launchlog.nedb')
  const db = new Datastore({filename:file, autoload:true})
  db.insert({time:new Date().getTime()})
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

//----------------------------------------
// IPC通信
//----------------------------------------
// ランキングデータを返却
ipcMain.handle('getRanking', async (event) => {
  const data = await ranking.getData()
  return(data)
})

プリロード - src/preload.js

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('myapi', {
    getRanking: async () => await ipcRenderer.invoke('getRanking')
  }
)

レンダラープロセス - public/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>AppStore売上ランキング</title>
  <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
</head>
<body>

<h1>AppStore売上ランキング</h1>
<ol id="result"></ol>

<script>
window.onload = async ()=>{
  // メインプロセスからデータ取得
  const rank = await getRanking()

  // 表示する
  rank.forEach((item)=>{
    addList('#result', item.title)
  })
}

/**
 * ランキングを取得
 */
async function getRanking(){
  const ranking = await window.myapi.getRanking()
  return(ranking)
}

/**
 * リストに追加する
 */
function addList(id, label){
  const result = document.querySelector(id)
  const li = document.createElement('li')
  const text = document.createTextNode(label)
  li.appendChild(text)
  result.appendChild(li)
}
</script>
</body>
</html>

NeDB操作モジュール - src/ranking.js

/**
 * ランキングデータ操作
 *
 * @version 1.0.0
 * @author M.Katsube
 */

//--------------------------------------------
// モジュール
//--------------------------------------------
const Datastore = require('nedb')
const path = require('path')

/**
 * RankingModelクラス
 */
class RankingModel{
  /**
   * コンストラクタ
   */
  constructor(){
    // data/ranking.nedbへのフルパスを作成
    const file = path.join(__dirname, '../data/ranking.nedb')
                      .replace('app.asar', 'app.asar.unpacked')

    // NeDBを開く
    this._db = new Datastore({filename:file, autoload:false})
    this._load = false
  }

  /**
   * NeDBにデータをロードする
   *
   * @returns {Promise}
   */
  load(){
    return new Promise((resolve, reject) => {
      this._db.loadDatabase( (err)=>{
        if(err){
          reject(err)
        }
        else{
          this._load = true
          resolve(true)
        }
      })
    })
  }

  /**
   * レコードを返却
   *
   * @param {number} [offset=0] 開始位置
   * @param {number} [limit=10] 個数
   * @returns {Promise}
   */
  async getData(offset=0, limit=10){
    if( ! this._load ){
      await this.load()
    }

    return new Promise((resolve, reject) => {
      this._db
          .find({})         // 全レコードを取得
          .sort({id:1})     // idで昇順にソート
          .skip(offset)     // offset位置まで移動
          .limit(limit)     // 個数制限
          .exec((err, doc)=>{
            err? reject(err):resolve(doc)
          })
    })
  }
}

//--------------------------------------------
// exports
//--------------------------------------------
const rank = new RankingModel()
module.exports = rank

NeDBの注意点

NeDBネタを書くと毎回触れている気がしますが、NeDBは長期間メンテナンスがされていません。今後再開されるか不透明なため、利用される場合はご注意ください。

ちなみに私もNeDBの代わりを探しておりまして、おすすめがあったらぜひ教えてください(・∀・) teratail.com

参考ページ