ハイサイ!オースティンやいびーん!
概要
Web WorkerでIndexedDBのデータベースを構築する方法を紹介します。
IndexedDBとは
IndexedDBはブラウザの中で起動しているデータベースのことです。
オブジェクトをシリアル化して保存できるので、JSON形式データベースのように使い勝手がいいです。Blobまでも保存できてしまいます。
ブラウザのセッションが終わっても情報は保存されます。
Web Storage API(localStorageおよびsessionStorage)より優れているのは、膨大なデータ量を扱う時です。
今回の実装:薬品名を予測する
今回は、ユーザーが入力する薬品名の推測をする仕組みを実装します。
ユーザの入力に対して素早く推測を出したいけれど、メーンスレッドを止めたくない、というのが目的と制約です。
上記のような課題だとAPIを叩けばいいだろうと思うでしょう。
確かにその手はあります。
しかし、以下のデメリットもあります。
- APIのレスポンスに時間がかかる
- 通信環境が不安定だとUXが打撃を喰らう
- FirebaseのようなBaaSだと、APIを作ると面倒な課題がある
そこで、IndexedDBをブラウザで構築しとけば良いという方針が出てきたわけです。
しかし、IndexedDBをクエリしていることはメーンスレッドを止めてしまうので避けたいです。
それで、Web WorkerでIndexedDBを操作するという発想が出てきます。
データを用意する
データベースに入れる情報は、厚生労働省が出している薬価のエクセルにある情報です。
これをCSV化して静的ホスティングのassets
に置いておきましょう。
筆者の場合、Angularを使っているので、src/assets/tp20180314-01_1.csv
に置いています。
内用薬,1121001X1018,ブロモバレリル尿素,1g,局,,,ブロモバレリル尿素,,,,,8.50 ,,
内用薬,1121001X1085,ブロモバレリル尿素,1g,局,,,ブロムワレリル尿素「メタル」,中北薬品,,,,9.10 ,H30.3.31まで,
内用薬,1121001X1131,ブロモバレリル尿素,1g,局,,※,ブロムワレリル尿素(山善),山善製薬,,,,9.10 ,,
上記のような行がずらりと一万件ほどあります。
2列目の薬価基準収載医薬品コード
をcode
と称してキーにします。
Web Workerを作っておく
Web Workerのファイルを作っておきます。筆者の場合はAngularなので以下のコマンドで生成してもらいます。
ng generate web-worker medicine-db
簡単にセットアップしてくれます。筆者の場合はprojects/medicine-notebook/src/app/medicine-db/medicine-db.worker.ts
にWeb Workerを置いてくれました。
読者が使っているフレームワークが用意してくれない場合は、assets
にmedicine.worker.js
を置いてそこにJavaScriptを直接書けば良いかと思います。
Web Workerの立ち上げ方等は本記事のスコープ外です。
CSVをassets
から読むロジック
medicine-db.worker.ts
でassets
からCSVを取得し、文字列化するロジックを追加します。
/// <reference lib="webworker" />
import { from, fromEvent, mergeMap, switchMap } from 'rxjs';
from(fetch('/assets/tp20180314-01_1.csv'))
.pipe(
mergeMap((result) => result.text()),
// mergeMap((csv) => worker.createDb$(csv)), // これは後で実装する
)
.subscribe();
データベースを操作するロジックを設計する
コードがごちゃごちゃする前に整理するためにデータベースを操作してくれるオブジェクトを設計しましょう。
まず、Abstract Classで設計図を作ります。
import { Observable } from 'rxjs';
export type Medicines = Map<string, Medicine>;
export interface Medicine {
name: string;
code: string;
manufacturer: string;
generic: boolean;
}
export abstract class DbWorkerAbstract<T extends Object> {
static dbName: string;
static storeName: string;
static version: number;
abstract createDb$(csvRaw?: string): Observable<IDBDatabase>;
abstract search$(field: keyof T, value: T[keyof T]): Observable<T[]>;
}
これでどのようなメソッドを用意すればいいかわかります。
この設計図をもとにそのDbWorker
を実装していきます。
import { DbWorkerAbstract } from './db-worker.abstract';
export class DbWorker extends DbWorkerAbstract<Medicine> {
}
CSVをパースするロジック
次、CSVをパースするロジックを追加します。
import { DbWorkerAbstract } from './db-worker.abstract';
export class DbWorker extends DbWorkerAbstract<Medicine> {
protected parseRawCsv(csvRaw: string): Medicines {
const endOfFirstLine = csvRaw.indexOf('\n');
const body = csvRaw.slice(endOfFirstLine + 1);
const rows = body.split('\n');
const rowsWithColumns = rows.map((row) => row.split(','));
return rowsWithColumns.reduce((acc, current) => {
if (!current[1]) return acc;
return acc.set(current[1], {
name: current[7],
code: current[1],
manufacturer: current[8],
generic: current[9] === '後発品',
});
}, new Map() as Medicines);
}
}
これでCSVの一行一行をMap
にセットして行っているので、IndexDBに入れる準備ができます。
IndexedDbを取得するロジックを書く
次にIndexedDBを開くロジックを追加します。
IndexedDBを開く場合、二つのパターンがあります。
- データベースがなかった、もしくはバージョンが古いから構築が必要
- データベースがすでにあったから構築が不要
構築が必要な場合、ロジックが変わるので、分岐しないといけません。
また、構築が不要なのに構築のロジックを実行するとエラーになるので、必ず分ける必要があります。
以下のロジックで分け方を解説します。
import { DbWorkerAbstract } from './db-worker.abstract';
export class DbWorker extends DbWorkerAbstract<Medicine> {
static override dbName = 'MedicineDatabase';
static override storeName = 'medicines';
static override version = 1;
createDb$(csvRaw: string) {
const medicines = this.parseRawCsv(csvRaw);
return this.getDb$().pipe(
mergeMap(({ db, needsUpgrade }) => (needsUpgrade ? this.upgradeDb$(db, medicines) : of(db))),
tap(() => {
this.readySubject.next(true);
}),
);
}
private getDb$() {
return new Observable<{ db: IDBDatabase; needsUpgrade: boolean }>((observer) => {
const controller = new AbortController();
const request = indexedDB.open(DbWorker.dbName, DbWorker.version);
request.addEventListener(
'success',
(event) => {
const db = (event.target as IDBOpenDBRequest).result;
observer.next({ db, needsUpgrade: false });
observer.complete();
},
{ signal: controller.signal },
);
request.addEventListener(
'upgradeneeded', // これは構築が必要な時に発火するイベント
(event) => {
const db = (event.target as IDBOpenDBRequest).result;
observer.next({ db, needsUpgrade: true });
observer.complete();
},
{ signal: controller.signal },
);
request.addEventListener('error', () => observer.error(), {
signal: controller.signal,
});
return () => controller.abort();
});
}
}
上記のupgradeneeded
イベントが配信された場合は、構築が必要なので、分岐をしてprivate upgradeDb$
を実行します。
private upgradeDb$(db: IDBDatabase, medicines: Medicines) {
return new Observable<IDBDatabase>((observer) => {
const controller = new AbortController();
const objectStore = db.createObjectStore(DbWorker.storeName, {
keyPath: 'code',
});
objectStore.transaction.addEventListener(
'complete',
() => {
observer.next(db);
observer.complete();
},
{ signal: controller.signal },
);
objectStore.transaction.addEventListener('error', () => observer.error(), { signal: controller.signal });
objectStore.createIndex('code', 'code', { unique: true });
objectStore.createIndex('name', 'name', { unique: false });
return () => {
controller.abort();
objectStore.transaction.abort();
};
}).pipe(
mergeMap(
(db) =>
new Observable<IDBDatabase>((observer) => {
const controller = new AbortController();
const transaction = db.transaction(DbWorker.storeName, 'readwrite');
transaction.addEventListener(
'complete',
() => {
observer.next(db);
observer.complete();
},
{ signal: controller.signal },
);
transaction.addEventListener('error', () => observer.error(), {
signal: controller.signal,
});
const medicinesStore = transaction.objectStore(DbWorker.storeName);
medicines.forEach((medicine) => medicinesStore.add(medicine));
return () => {
controller.abort();
transaction.abort();
};
}),
),
);
}
createObjectStore
でIndexDBのテーブルを作りますが、テーブルには主キーが必要なので、オブジェクトの中に入っているキーになるような値を指定します。今回の場合は、code
です。
objectStore.transaction
でインデックス作成が終わるまでデータ追加のフェーズに入らないようにします。
objectStore.transaction
がcomplete
を配信する前にデータを追加しようとすると、エラーになります。
objectStore.createIndex
でクエリのためにインデックスを追加していますが、これは任意です。
データベースのテーブル作成が終われば、次はデータを追加します。medicinesStore.add(medicine)
で行っています。
検索メソッドを実装する
ここまでだと、データベースが構築されているので、検索するロジックを追加すればクエリができます。
IndexedDBのcursor
を使えば、データベースのエントリーを一件ずつ見ることができます。
以下の実装を見ましょう。
import { DbWorkerAbstract } from './db-worker.abstract';
export class DbWorker extends DbWorkerAbstract<Medicine> {
search$(field: keyof Medicine, searchValue: string | boolean): Observable<Medicine[]> {
return this.getDb$().pipe(
map(({ db }) => db),
switchMap(
(db) =>
new Observable<Medicine>((observer) => {
const controller = new AbortController();
let finished = false;
const transaction = db.transaction([DbWorker.storeName], 'readonly');
transaction.addEventListener(
'complete',
() => {
observer.complete();
},
{ signal: controller.signal },
);
transaction.addEventListener('error', () => observer.error(transaction.error), {
signal: controller.signal,
});
const regex = new RegExp(`^${searchValue}`);
const store = transaction.objectStore(DbWorker.storeName);
const cursorRequest = store.openCursor();
cursorRequest.addEventListener(
'success',
(event) => {
const cursor = (event.target as IDBRequest).result as IDBCursorWithValue;
if (!cursor) return observer.complete();
const value = cursor.value as Medicine;
const isPartialMatch = value[field].toString().match(regex);
if (isPartialMatch) {
observer.next(value);
}
if (!finished) cursor.continue();
},
{ signal: controller.signal },
);
cursorRequest.addEventListener('error', () => observer.error(cursorRequest.error), { signal: controller.signal });
return () => {
finished = true;
controller.abort();
transaction.abort();
};
}),
),
take(10),
toArray(),
);
}
const store = transaction.objectStore(DbWorker.storeName);
でテーブルを取得し、const cursorRequest = store.openCursor();
で更にカーサーを取得しています。
カーサーのsuccess
コールバックで検索のロジックを正規表現で実装していますが、当たった場合はObserverに流します。あたりが10件出るか、全てのデータベースのエントリーを検索したかのどれかまで処理を続けます。
これで検索のロジックはOKです!
Web Workerを完成させる
このDbWorkerオブジェクトをWeb Workerで生成して置いてロジックを完成させましょう。
/// <reference lib="webworker" />
import { from, fromEvent, mergeMap, switchMap } from 'rxjs';
import { DbWorker } from './db-worker';
const worker = new DbWorker();
const message$ = fromEvent<MessageEvent<string>>(self, 'message');
message$.pipe(switchMap(({ data }) => worker.search$('name', data))).subscribe((results) => {
self.postMessage(results);
});
from(fetch('/assets/tp20180314-01_1.csv'))
.pipe(
mergeMap((result) => result.text()),
mergeMap((csv) => worker.createDb$(csv)),
)
.subscribe();
AngularでWeb Workerを組み込みます
Angularでこれを使う場合は以下のように実装します。
MedicineDbService
import { Injectable } from '@angular/core';
import { fromEvent, Observable } from 'rxjs';
import { Medicine } from './medicine-db.model';
@Injectable({
providedIn: 'root',
})
export class MedicineDbService {
worker?: Worker;
message$: Observable<MessageEvent<string[]>> = new Observable();
constructor() {
if (typeof Worker !== 'undefined') {
this.worker = new Worker(new URL('./medicine-db.worker', import.meta.url));
this.message$ = fromEvent<MessageEvent>(this.worker, 'message');
}
}
search$(text: string) {
return new Observable<Medicine[]>((observer) => {
if (!this.worker) throw ReferenceError();
this.worker.addEventListener(
'message',
({ data }) => {
observer.next(data);
observer.complete();
},
{ once: true },
);
this.worker.postMessage(text);
});
}
}
なお、Web Workerが使えないブラウザに対応したい場合は、メーンスレッドで実行するしかありません。
部品ロジック
部品側で使う場合は、ユーザーの入力に対して検索の処理を走らせます。
import { Component, inject } from '@angular/core';
import { FormControl } from '@angular/forms';
import { switchMap } from 'rxjs';
import { MedicineDbService } from './medicine-db/medicine-db.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
private medicineDbService = inject(MedicineDbService);
searchInput = new FormControl<string>('', { nonNullable: true });
searchResult$ = this.searchInput.valueChanges.pipe(switchMap((value) => this.medicineDbService.search$(value)));
handleClick(value: string) {
this.searchInput.setValue(value, { emitEvent: false });
}
}
テンプレート
<p>results</p>
<ul *ngIf="this.searchResult$ | async as results">
<li *ngFor="let result of results" (click)="this.handleClick(result.name)">
{{ result.name }}
</li>
</ul>
<input type="text" [formControl]="this.searchInput">
以下のような結果になります。
まとめ
IndexedDBをWeb Workerで実行する実装はいかがでしょうか?Web WorkerもIndexedDBもJavaScriptの中で上級者向けな気がしますが、上記の実装のように、うまいこと使えば、とてもいいUXを実現できます。
多くの場合、推測はAPIを叩いていますが、APIが保持している情報(今回は薬品名)があまり変わらなければ、特にSPAの場合だと、ブラウザでデータベースを構築しておくことは一つの手段かと思います。