Web WorkerでIndexedDBを使おう

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

# 概要

Web WorkerでIndexedDBのデータベースを構築する方法を紹介します。

## IndexedDBとは

IndexedDBはブラウザの中で起動しているデータベースのことです。

オブジェクトをシリアル化して保存できるので、JSON形式データベースのように使い勝手がいいです。Blobまでも保存できてしまいます。

ブラウザのセッションが終わっても情報は保存されます。

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API

Web Storage API（localStorageおよびsessionStorage）より優れているのは、膨大なデータ量を扱う時です。

# 今回の実装：薬品名を予測する

今回は、ユーザーが入力する薬品名の推測をする仕組みを実装します。

ユーザの入力に対して素早く推測を出したいけれど、メーンスレッドを止めたくない、というのが目的と制約です。

上記のような課題だと**APIを叩けばいいだろう**と思うでしょう。

確かにその手はあります。

しかし、以下のデメリットもあります。

- APIのレスポンスに時間がかかる
- 通信環境が不安定だとUXが打撃を喰らう
- FirebaseのようなBaaSだと、APIを作ると面倒な課題がある

そこで、**IndexedDBをブラウザで構築しとけば良い**という方針が出てきたわけです。

しかし、IndexedDBをクエリしていることはメーンスレッドを止めてしまうので避けたいです。

それで、**Web WorkerでIndexedDBを操作する**という発想が出てきます。

# データを用意する

データベースに入れる情報は、厚生労働省が出している薬価のエクセルにある情報です。

https://www.mhlw.go.jp/topics/2016/04/tp20160401-01.html

これをCSV化して静的ホスティングの`assets`に置いておきましょう。

筆者の場合、Angularを使っているので、`src/assets/tp20180314-01_1.csv`に置いています。

```text
内用薬,1121001X1018,ブロモバレリル尿素,１ｇ,局,,,ブロモバレリル尿素,,,,,8.50 ,,
内用薬,1121001X1085,ブロモバレリル尿素,１ｇ,局,,,ブロムワレリル尿素「メタル」,中北薬品,,,,9.10 ,H30.3.31まで,
内用薬,1121001X1131,ブロモバレリル尿素,１ｇ,局,,※,ブロムワレリル尿素（山善）,山善製薬,,,,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を取得し、文字列化するロジックを追加します。

```typescript: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で設計図を作ります。

```typescript: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`を実装していきます。

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

export class DbWorker extends DbWorkerAbstract<Medicine> {
}
```

# CSVをパースするロジック

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

```typescript: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. データベースがなかった、もしくはバージョンが古いから構築が必要
1. データベースがすでにあったから構築が不要

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

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

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

```typescript: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$`を実行します。

```typescript
  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`を使えば、データベースのエントリーを一件ずつ見ることができます。

以下の実装を見ましょう。

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

```typescript: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

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


### 部品ロジック

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

```typescript: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 });
  }
}
```

### テンプレート
```html
<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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/e26e29b2-c234-2338-51a9-16ffb6e28112.gif)

# まとめ

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

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