はじめに
こんにちは。
昨年はVue.jsを使ってRPG風のTodoアプリを完成させたのですが、アプリが肥大化して状態の管理ができなくなってきそう(まだギリギリできる)なのと、関数型言語を勉強したいという欲求から、Elmを使ってこのアプリをゼロから再構築しようと考えています。
Elmを始めて1か月ちょっとですが、すこしずつコードが書き進まってきました。
ものができてくるのはうれしいものですね。
IndexedDBとの連携
さて、Elmが簡単!とは口が裂けても言えませんが、関数を基本として記述すること、値の再代入を許さないこと、型を合わせること、この三つを意識すれば、あとはコンパイラがある程度道筋を作ってくれるので、Elmの中にいる限りは非常に安心感があります。テストもやりやすいですので、テスト駆動開発とも相性がよさそうです(こちらも目下勉強中)。
ただ、そんな中避けて通れないのがjavascriptとの連携です。私が作りたいTodoリスト1つ取ってしても、データの永続化は避けて通れない問題ですし、その際にサーバーサイドとやり取りせずにlocalStorage等で簡易に済ませたい人(特に私が)も多いと思います。
Elmの公式ドキュメントやプログラミングElmといった参考書には当然連携の仕方も書いてあるのですが、主に対象はWebSocketやlocalStorageであり、今回の表題であるIndexedDBとの連携を書いてある記事は少なく思いますので、忘備録の意味も込めて記事にしたいと思います。
IndexedDBとは
IndexedDBについては、こちらに特徴が書いてあります。長いですが、localStorageと比較すると大容量であること、型含めてきちんと保存できることがまず特徴にあげられると思います。が、IndexedDBを積極的に使いたい理由は、主に下記の記事の影響が大きいです。localStorage絶対使うな説ですねw
Dexie.jsを使う
Dexie.jsとは
そんなこんな(どんな?)でIndexedDBを使っていきたいのですが、チュートリアル的なドキュメントを眺めるだけでも数日経過してしまいそうで、そのまま使うのは難しそう...なので、適切なラッパーを使用すればいいじゃん?とドキュメントの最後にもこっそり紹介してありますので、お言葉に甘えることにします。
その中で、ドキュメントが割としっかりしていて比較的簡単に使えるのがDexie.jsです。あまりデータベース関連は詳しくないのですが、検索、絞り込み等々の機能が割と(あくまでも割と)直感的に使えます。※1
Elmとの連携
さて、本題のElmとの連携ですが、Elm側の準備はlocalStorageに保管するときと全く同じです。下記に、関係のあるコードを記述します。
※雰囲気で記述していますので、このままでは当然動きません。mainの種類(element, document等々)や型の種類に合わせて随時読み替えてください。
-- Mainモジュールの先頭にportを記述
port module Main exposing (..)
-- JSON形式データのDecoderとEncoderを準備
import Json.Decode as Decode
import Json.Encode as Encode
:
-- flagsで外部からのデータを取り込む
-- Encodeされている?Valueなので、decodeしてOk, Ng判定。
init Encode.Value -> (Model, Cmd Msg)
init flags =
case Decode.decodeValue testDecoder flags of
-- flagsをDecodeしたものがOkの中にModel型として保持されている。
Ok model ->
(model, Cmd Msg)
Err _ ->
(initialModel, Cmd Msg)
:
-- MODEL
type alias Model =
{ id : Int
, name : String
}
:
-- PORT
-- JS側に出力する命令
port setStorage : Encode.Value -> Cmd msg
:
-- ENCODER/DECODER
-- 外部にElmのデータを渡す
testEncoder : Model -> Encode.Value
testEncoder model =
Encode.object
[ ("id", Encode.int model.id)
, ("name", Encode.string model.name)
]
-- 外部からのJSONデータをDecodeする
testDecoder : Decode.Decoder Model
testDecoder =
Decode.map2 Model
(Decode.field "id" Decode.int)
(Decode.field "name" Decode.string)
Elm側ではDatabaseに登録したいデータをJSに投げるために変換するEncoder, 外部にそれを伝えるportの関数(上記の例ではsetStorage), 外部からデータが来た時に変換するDecoder, それをinitで取り込むflagsの4つが準備できればOKです。あとは、DBに保存したいタイミング(データの書き換えなど)でEventを発火させてupdateするために、updateのラッパー関数を作ります。
updateWithStorage : Msg -> Model -> (Model, Cmd Msg)
updateWithStorage msg oldModel =
let
( newModel, cmds ) = update msg oldModel
in
case msg of
Event -> ( newModel,
Cmd.batch [ setStorage (testEncoder newModel) ]
updateが何らかのmsgを受け取ったタイミングでnewModelを作成し、そのメッセージがEventであれば外向きにもメッセージ(setStorage)を伝える、というイメージでしょうか。setStorageの引数にはEncodeしたModelが入っています。※2
なお、ここまでの記述は、実際のところlocalStorageのやり取りと全く変わりありませんので、他の参照先を見ていただければ豊富に例があるかと思います。
javascript側の記述
続いて、Javascript側の記載ですが、こちらもある意味非常に単純です。ただし、下記2点で若干ハマりました。
- Dexie.jsの命令が非同期処理であること
- Dexie.jsで取得したデータが直接JSON.parseできないこと
上記2点をふまえてコードを見ていただければと思います。
// ElmとDexieのimpote
import { Elm } from "../src/Main.elm"
import { Dexie } from "dexie";
// 全体をasyncで囲む
async function main() {
// !! 得られるデータがJSON形式になっていないため、JSON.stringifyで変換
// !! ここのgetData()は非同期処理なので、awaitをつけないとデータがうまく取り出せない
const storageData = JSON.strigify(await getData());
// Elm側でDecodeするためにJSONとしてparseする。
const flags = storageData ? JSON.parse(storageData) : null;
// Elm側にflagsでデータを渡す
const app = Elm.Main.init({
node: document.getElementById("main"),
flags: flags
});
// Elm側から来た信号(port)を受け取って命令を実行
// stateにはElm側でEncodeされたデータが入っている。
app.ports.setStatusStorage.subscribe(function(state) {
setDB(state);
});
}
// dbは全体で使えるように定義
let db;
function openDB() {
// dbがインスタンス化されていない時だけ実行
if (!db) {
console.log("create new database");
db = new Dexie("TestDatabase");
db.version(1).stores({
data: "id, name"
});
}
db.open();
}
async function setDB(state) {
openDB();
await db.data.put(state);
db.close();
}
async function getDB () {
openDB();
// dexieのデータはそのままでは単なるobjectなのでArrayに変換することでいろいろと扱えるようになる。
const data = await db.data.toArray();
db.close();
// いったん単一データを想定しているので、indexとして0をつけている。
// 複数データの場合はそのまま返す。ただしElmのDecoderをいじる必要あり。
return data[0]
}
// 最後にこっそりmain()を実行。これがないと当然ですが動かない。
main();
一般的にElmのドキュメントなどで紹介してあるように、javascriptにmainのコードべた書きすると、Dexie.jsの非同期処理がうまく扱えません。そのため、main関数を作って全体をasync mainで囲っています。いくらgetDB関数のところだけasync/awaitにしても全くデータが取り込まれず苦悶しました… ※3
もう一つは、Elm側のDecoderがJSON形式のみ受け入れてくれますので、JSON.parseを通す必要があります。ですが、Dexieの生オブジェクトに対しては直接JSON.parseが使えません。そのため、取り込んだデータを一度JSON.stringifyして、parseできるStringに直してやる必要がありました。今回はテーブルが1つなので比較的単純な処理ですが、今やっている複数テーブルからのparseを実行しようとすると、ここをきちんと意識しないと全然データを通してくれません。(もちろん、単一データでも通してくれませんが) ここらへん、もう少しうまい方法がありそうですが…
また、Elmらしい点として、データを渡すときにしれっとnullを渡しています。この時、Elm側では「Decodeできないよ!」という旨のErrをを投げてくれて、実行エラーにはなりません。ここは、nullでなくても、もし渡すデータ形式が間違っていた場合、js側(もしくはそもそものデータ定義)に非があることがわかりますので、デバッグ作業がはかどるように思います。※4
まとめ
Databaseを扱うという割と面倒な処理である割に、Elm側では驚くほど基本的な記述で済むことが大変魅力的に感じます。
今回のケースは一番単純なケースですが、次回はSPAとした場合にどのように記述するか、複数テーブルからのデータを処理するにはどうしたらよいか、など書けたらなと思っています。
※内容で間違いや勘違いがあればご指摘いただけるとありがたいです。
補足
※1 Dexie.jsの導入について
Dexie.jsをJavascriptに導入する方法は検索すれば山ほど出てきますし、公式ドキュメントの中にも簡単に記載がありますので省きます。
※2 Elmの堅牢さについて
私だけの感触かもしれませんが、Elmは堅牢に守られた城のように見えます。外部に書状を出すにしても最新の注意を払って、外からの情報も極力検閲をかけて内側の安全性を守っている、そんな感じですね。
ただ、それは実際嫌な感じはしないです。実際、以前Dexie.jsを使って書いた時よりも、javascript側でどのようにデータを処理すべきかきちんと考えてコードを記述するようになりました。一部であっても厳格にコードの堅牢さを意識することは、プログラミング全体の質を高める、そんな気がしています。
※3 parcelでasync, awaitを使う方法
Elmを書くにあたってbuildにparcelを使っていますが、素のままだとうまくasync, awaitが動きませんでした。調べたら下記の記事がありました。無事動きました。
※4 nullについて
ElmのDecoderにはnullableという、Ok _ or Nothing を返すDecoderがあるのですが、これにきちんと判定してもらうためには厳密にnullである必要があります。これについてはまた別でまとめたいと思っています。