nakamurakko’s blog

仕事で覚えたこと、勉強したことを自分のメモ代わりに書いていこうかなと。

Angular、 SQLite を使った Electron アプリケーションを作ってみた

UI を Angular、 DB を SQLite とした Electron をベースのアプリケーションを作ってみた。サンプルソースは https://github.com/nakamurakko/angular-electron-sqlite-sample にアップロード。

環境

  • Angular 17
  • Electron 28
  • SQLite3 5
  • TypeORM 0.3
  • reflect-metadata 0.2

プロジェクトの準備

ng new で Angular 新規プロジェクトを作成後、 npm install --save-dev electron で Electron のパッケージを追加する。 DB に SQLite、 OR マッパーに TypeORM を使用するので npm install sqlite3 typeorm で追加する。 TypeORM の インストールの説明を確認すると reflect-metadata のインストールが必要と記載があるため npm install reflect-metadata で追加する。

データアクセスの大まかなイメージは下記の通り。

Electron 側の設定

Electron の起動構成

Electron 関連のソースファイルの保存場所は、 src-electron とした。

Electron のクイック スタートを参考に electron-main.ts を作成する。(main.ts だと Angular のファイル名が重複するので、 Electron 側の main.ts は electron-main.ts とした。) createWindow() で、Angular の index.html を読み込むように変更した。

await mainWindow.loadFile(path.join(__dirname, 'angular-electron-sqlite-sample/index.html'));

Entity クラス

DB(SQLite)アクセスするフレームワークに TypeORM を使用する。 SQLite へのアクセスは Electron 側から行うので、 SQLite 用のエンティティクラスは src-electron\entities で管理する。

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

/**
 * ユーザー Entity クラス。
 */
@Entity()
export class User {

  @PrimaryGeneratedColumn('uuid')
  public id?: string;

  @Column()
  public firstName?: string;

  @Column()
  public lastName?: string;

  @Column({ nullable: true })
  public portrait?: string;

}

User クラスは DataSource に Entitiy として登録する。

export const AppDataSource: DataSource = new DataSource({
  type: "sqlite",
  database: "database.sqlite",
  synchronize: true,
  logging: false,
  entities: [User],
  migrations: [],
  subscribers: [],
});

Entitiy インターフェイス

Electron > Electron のプロセス > プロセス間通信 > オブジェクトのシリアライズにある通り、 Angular - Electron 間は構造化複製アルゴリズムに記載された型でやりとりする。つまり、 TypeORM の Decorator を追加した Entity クラスではやりとりできないため、 Entity クラスと同じプロパティを持っているインターフェイス(ここでは Entitiy インターフェイスと呼ぶ事にする。)を使ってやりとりする。

まず、 User テーブルと対になる IUser インターフェイスを作成する。

/**
 * ユーザー Entitiy インターフェイス。
 */
export interface IUser {

  /** ID。 */
  id?: string;

  /** 名。 */
  firstName?: string;

  /** 姓。 */
  lastName?: string;

  /** 写真。 Data URL + base64 データで文字列化する。 */
  portrait?: string;
}

ディレクトリーはプロジェクト全体で参照できるように TypeScript > Publish to @types を参考にして src/@types/entities/interfaces とした。(Electron 側でインポートする場合に相対パスになって若干面倒くさいけど、インターフェイスの定義を統一できるメリットを優先する。)

作成したインターフェイスを、先ほど作成した User クラスの implements に追加する。

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

import { IUser } from '../../src/@types/entities/interfaces/i-user';

/**
 * ユーザー Entity クラス。
 */
@Entity()
export class User implements IUser {

  @PrimaryGeneratedColumn('uuid')
  public id?: string;

  @Column()
  public firstName?: string;

  @Column()
  public lastName?: string;

  @Column({ nullable: true })
  public portrait?: string;

}

プロセス間通信を利用した DB アクセス

Angular 側からデータ取得処理を呼び出せるように、 Electron > プロセス間通信 > パターン 2: レンダラーからメインへ (双方向)を参考に DB アクセスを実装する。

まずは ipcMain.handle で SQLite にアクセスしてデータを取得し、取得結果をインターフェイスで返す IPC(プロセス間通信) Handler を実装する。 channel 名は getUsers とした。

ipcMain.handle('getUsers',
  /**
   * ユーザー一覧を取得する。
   *
   * @returns ユーザー一覧。
   */
  async (): Promise<Array<IUser>> => {
    return await AppDataSource.getRepository(User)
      .find()
      .then(value => value.map(x => x as IUser));
  });

preload.js で公開する。 apiKey は dbApi、 channel 名は getUsers とした。

import { contextBridge, ipcRenderer } from 'electron';

import { IUser } from '../src/@types/entities/interfaces/i-user';

contextBridge.exposeInMainWorld('dbApi', {
  /**
   * ユーザー一覧を取得する。
   *
   * @returns ユーザー一覧。
   */
  getUsers: (): Promise<Array<IUser>> => ipcRenderer.invoke('getUsers').then(value => value as Array<IUser>)
});

最後にコンテキストの分離 > TypeScript との連携を参考に、 renderer.d.ts を用意して、公開した API を Angular 側に参照させる。 renderer.d.ts の実装が完了すれば Angular 側から呼び出せるようになる。

import { IUser } from 'src/@types/entities/interfaces/i-user';

declare global {
  interface Window {
    dbApi: IDbApi
  }
}

/**
 * dbApi 用インターフェイス。
 */
export interface IDbApi {

  /**
   * ユーザー一覧を取得する。
   *
   * @returns ユーザー一覧。
   */
  getUsers(): Promise<Array<IUser>>;

}

Angular 側の設定

Electron で用意した DB アクセスは Promise で実装しているけど、 Angular 内では基本的に Observable を使いたいので、 DB API アクセス用 Service を用意して、 Observable に変換する。

import { defer, Observable } from 'rxjs';
import { IUser } from 'src/@types/entities/interfaces/i-user';

import { Injectable } from '@angular/core';

/**
 * DbApi 用サービス。
 */
@Injectable({
  providedIn: 'root'
})
export class DbApiService {

  public constructor() { }

  /**
   * ユーザー一覧を取得する。
   *
   * @returns ユーザー一覧。
   */
  public getUsers(): Observable<Array<IUser>> {
    return defer(() => window.dbApi.getUsers());
  }

}

後は画面を実装して、呼び出せば良い。

import { delay, finalize } from 'rxjs';
import { IUser } from 'src/@types/entities/interfaces/i-user';
import { DbApiService } from 'src/app/services/db-api.service';

import { Component, OnInit } from '@angular/core';

/**
 * ユーザー情報表示用コンポーネント。
 */
@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

  /** 一覧のヘッダー。 */
  public displayedColumns: Array<string> = ['lastName', 'firstName', 'showUserDetail', 'editUser'];
  /** ユーザー一覧。 */
  public users: Array<IUser> = new Array<IUser>();

  /**
   * コンストラクター。
   *
   * @param dbApiService DB サービス。
   */
  public constructor(
    private dbApiService: DbApiService
  ) {
  }

  public ngOnInit(): void {
    this.progressService.showProgress();
    this.dbApiService.getUsers()
      .subscribe(value => {
        this.users = value;
      });
  }

}

README を参考にデバッグすれば、下記の通り表示される。