LoginSignup
5
2

More than 1 year has passed since last update.

Web WorkerでIndexedDBを使おう

Last updated at Posted at 2023-03-14

ハイサイ!オースティンやいびーん!

概要

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を置いてくれました。

読者が使っているフレームワークが用意してくれない場合は、assetsmedicine.worker.jsを置いてそこにJavaScriptを直接書けば良いかと思います。

Web Workerの立ち上げ方等は本記事のスコープ外です。

CSVをassetsから読むロジック

medicine-db.worker.tsassetsからCSVを取得し、文字列化するロジックを追加します。

medicine-db.worker.ts
/// <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で設計図を作ります。

db-worker.abstract.ts
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を実装していきます。

db-worker.ts
import { DbWorkerAbstract } from './db-worker.abstract';

export class DbWorker extends DbWorkerAbstract<Medicine> {
}

CSVをパースするロジック

次、CSVをパースするロジックを追加します。

db-worker.ts
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を開く場合、二つのパターンがあります。

  1. データベースがなかった、もしくはバージョンが古いから構築が必要
  2. データベースがすでにあったから構築が不要

構築が必要な場合、ロジックが変わるので、分岐しないといけません。

また、構築が不要なのに構築のロジックを実行するとエラーになるので、必ず分ける必要があります。

以下のロジックで分け方を解説します。

db-worker.ts
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.transactioncompleteを配信する前にデータを追加しようとすると、エラーになります。

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で生成して置いてロジックを完成させましょう。

medicine-db.worker.ts
/// <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

medicine-db.service.ts
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が使えないブラウザに対応したい場合は、メーンスレッドで実行するしかありません。

部品ロジック

部品側で使う場合は、ユーザーの入力に対して検索の処理を走らせます。

app.component.ts
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">

以下のような結果になります。

ezgif.com-video-to-gif.gif

まとめ

IndexedDBをWeb Workerで実行する実装はいかがでしょうか?Web WorkerもIndexedDBもJavaScriptの中で上級者向けな気がしますが、上記の実装のように、うまいこと使えば、とてもいいUXを実現できます。

多くの場合、推測はAPIを叩いていますが、APIが保持している情報(今回は薬品名)があまり変わらなければ、特にSPAの場合だと、ブラウザでデータベースを構築しておくことは一つの手段かと思います。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2