🦇

React × Electron × TypeORMで作成するMarkdownアプリ ~ バックエンド編 ~

2024/10/07に公開

はじめに

YoutubeでElectronを使用してマークダウンを実装するという動画を見かけ、丁度実務でElectronを触り始めたので、動画を参考にしながら色々と実装してみることにしました。

https://www.youtube.com/watch?v=t8ane4BDyC8&list=WL&index=48&t=4821s


バックエンドのDB操作はTypeORMを使用し、フロント側はReactとJotaiを使用してます。主にバックエンド側のElectron × TypeORMで実装した内容について共有して行きたいと思います。今回実装した内容は以下リポジトリに格納しています。

https://github.com/MASAKi-cell/Doon



バージョン情報

本実装のライブラリ、言語などのバージョン情報です。

ライブラリ・言語 バージョン
Electron 3.0.0
TypeORM 0.3.20
sqlite3 5.1.7
React 18.2.0
typescript 5.3.3



使用技術

Electron

デスクトップアプリケーションを作成するためのフレームワークで、異なるOS(Windows、macOS、Linux等)で同じ仕様のアプリケーションを動かすことができます。Electronではメインプロセスとレンダラープロセスが存在し、それぞれ実装していくことになります。

  • メインプロセスはNode.js環境で動作し、ElectronのAPIやNode.jsのAPIにフルアクセス可能です。
  • レンダラープロセスはChromium(Web)環境で動作し、WebAPIへのアクセスがメイン

https://www.electronjs.org/

Electronについては以下のZennBookの解説が分かりやすいです。

https://zenn.dev/sprout2000/books/6f6a0bf2fd301c/viewer/13263


TypeORM

DBのテーブル操作に使用するTypeScriptで記載するORMライブラリです。DBのテーブルやビューを、プログラム内のクラスやオブジェクトにマッピングし、SQLを直接書かずにデータベース操作が可能です。

https://typeorm.io/



環境構築

まず初めにElectron × React × TypeScriptの環境を構築する為に、create-electronを使用します。今回はReactを使用する為--templatereactを指定します。

 npm create @quick-start/electron my-app -- --template react
Need to install the following packages:
  @quick-start/create-electron
Ok to proceed? (y) y
✔ Add TypeScript? … No / Yes
✔ Add Electron updater plugin? … No / Yes
✔ Enable Electron download mirror proxy? … No / Yes

質問事項にそれぞれ回答すると、my-app内にmainrendererファイルがそれぞれ作成されます。npm run devでアプリが立ち上がればインストール成功です。

またElectron をホットリロード(開発中にコードを変更した場合、直ぐに反映され、アプリを再起動することなく、リアルタイムな動作確認が可能)してくれるツールをインストールしておきます。

https://www.npmjs.com/package/electron-reload

npm install --save-dev electron-reload



tsconfig.jsonの設定

tsconfig.jsonファイルを設定します。tsconfig.node.jsontsconfig.web.jsonが存在しますが、Electronではメインプロセス(Node.js環境)とレンダラープロセス(Webブラウザ環境)のコードが含まれる為、両方の設定が必要になります。

tsconfig.web.json
{
  "extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
  "include": [
    "src/renderer/src/env.d.ts",
    "src/renderer/src/**/*",
    "src/renderer/src/**/*.tsx",
    "src/preload/*.d.ts"
  ],
  "compilerOptions": {
    "jsx": "react-jsx",
    "noUnusedLocals": true, // 未使用の変数を禁止
    "noUnusedParameters": true,  // 未使用の引数を禁止
    "noImplicitAny": true, // 暗黙のanyを禁止
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@renderer/*": [
        "src/renderer/src/*"
      ],
      "@main/*": [
        "src/main/*"
      ]
    }
  }
}



Viteの設定

次にviteの設定を行います。electron-viteはChromiumとNode.jsの2つの環境を同時に処理する機能を備えており、レンダラープロセスの場合は、ViteのHMR(ソースコードに変更があった場合、差分のみブラウザに適用してくれる機能)を使用し、メインプロセスの場合は、ソースコードとプリロードスクリプトが直接バンドルされます。

https://electron-vite.org/guide/introduction

vite.config.tsmainpreloadrendererを個別に設定を行います。

electron.vite.config.ts
export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    resolve: {
      alias: {
        '@main': resolve('src/main'),
        '@utils': resolve('src/main/utils')
      }
    }
  },
  preload: {
    plugins: [externalizeDepsPlugin()]
  },
  renderer: {
    assetsInclude: 'src/renderer/assets/**',
    resolve: {
      alias: {
        '@renderer': resolve('src/renderer/src'),
        '@/components': resolve('src/renderer/src/components'),
        '@/hooks': resolve('src/renderer/src/hooks'),
        '@/assets': resolve('src/renderer/src/assets'),
        '@/store': resolve('src/renderer/src/store'),
        '@/mocks': resolve('src/renderer/src/mocks')
      }
    },
    plugins: [react()]
  }
})
  • resolve.alias:aliasをmainプロセスとrendererの両方に設定し、@main@rendererでimportできるように設定します。
  • externalizeDepsPlugin: 依存関係を外部化することで、パフォーマンスやビルド効率を向上させます。
  • plugins: [react()]: viteでreactで作成したUIをビルドするために設定します。



メインプロセスの設定

Electronのメインプロセスの設定をindex.tsに記載していきます。

src/main/index.ts
  // windowを作成
  const mainWindow = new BrowserWindow({
    width: 1200,
    height: 900,
    show: false,
    center: true, // 中央配置
    title: 'Doon NoteBook',
    vibrancy: 'appearance-based', // macOS ウインドウに曇ガラスのエフェクトの設定
    visualEffectState: 'active', // macOS ウインドウの動作状態を設定
    titleBarStyle: 'hidden', // タイトルバーを隠す
    titleBarOverlay: true, // ウィンドウコントロールオーバーレイ
    trafficLightPosition: { x: 15, y: 10 }, // フレームレスウインドウの信号機ボタンのカスタム位置を設定
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: true, // サンドボックス
      contextIsolation: true // コンテキストの分離
    }
  })
  • BrowserWindowでブラウザウィンドウのサイズや制御を行います。
  • sandbox: trueでプロセスのサンドボックス化を行い、アクセスを制限することで悪意のあるコードが引き起こす被害を制限します。
  • contextIsolation: trueでメインプロセスとレンダラープロセスのコンテキストを分離します。

https://www.electronjs.org/ja/docs/latest/tutorial/sandbox

https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation



型定義の追加

今回作成するnoteの型を定義します。

src/main/contents/ipc.ts
export type NoteInfo = {
  uuid: string
  title: string
  content?: string
  lastEditTime: Date
}

export type NoteContent = string | undefined
export type valueOf<T> = T[keyof T]

ファイルを一意に特定するためにUUIDを使用します。今回はUUID v7を使用します。

https://zenn.dev/kazu1/articles/e8a668d1d27d6b



エラーハンドリング

utilsにエラーハンドリング用の関数を作成しておきます。APIの処理で失敗すれば、エラーを返却してくれる為、エラーハンドリングが容易になります。

src/main/utils/handler.ts
/**
 * エラーハンドリング用function
 * @param promise API処理関数
 * @param defaultError
 */
export const handleError = async <T>(
  promise: Promise<T>,
  defaultError: any = 'rejected'
): Promise<(T | undefined)[] | [T, any]> => {
  try {
    const data = await promise
    return [data, undefined]
  } catch (error) {
    return await Promise.resolve([undefined, error || defaultError])
  }
}



typeORMの実装

次にDBのテーブル操作を行うために、typeORMを導入します。

https://typeorm.io/

typeORMの操作に必要なパッケージをインストールします。

npm install typeorm reflect-metadata sqlite3
npm install @types/node --save-dev

ormcofig.tsの設定は以下のとおりです。

import path from 'path'
import { DataSourceOptions } from 'typeorm'

export const ormconfig: DataSourceOptions = {
  type: 'sqlite',
  database: 'database.sqlite',
  entities: [`${path.join(__dirname, '/model')}/*.js`],
  migrations: [`${path.join(__dirname, '/migrations')}/*.js`],
  migrationsRun: true, // マイグレーション同時実行
  synchronize: false
}

https://zenn.dev/msksgm/articles/20211107-typeorm-ormconfig


tsconfig.node.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // 他の設定は適宜追加
  }
}

tsconfig.jsonファイルに以下の設定を追加し、デコレーターを有効にします。


公式ドキュメントに従いつつ、初期化処理を記載します。DB未接続の時のみ、DB接続を行います。

src/main/database/index.ts
export default class Database {
  private static _conn?: DataSource

  public static async createConnection(): Promise<DataSource> {
    if (!this._conn) {
      this._conn = new DataSource(ormconfig)
      await this._conn.initialize()
      await this._conn.runMigrations()
    }
    return this._conn
  }

  public static async close(): Promise<void> {
    if (!this._conn) {
      return
    }
    if (this._conn && this._conn.isInitialized) {
      await this._conn.destroy()
    }
    this._conn = undefined
  }
}


Electronアプリの起動時と終了時にDB接続する処理をメインプロセスに追加します。

src/main/index.ts
app.on('will-finish-launching', async (): Promise<void> => {
  // DB接続
  await Database.createConnection()
})

// DB接続の終了
app.on('will-quit', (): void => {
  Database.close()
})

typeORMのv2.0とv0.3は立ち上げ方が異なるとのことで以下の記事が参考になりました。

https://blog.rhyztech.net/typeorm_0.2_to_0.3/



Entityを追加

DBのエンティティを追記します。uuidをprimary keyとして設定します。

src/main/database/model/noteInfo.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { NoteInfo } from '@main/contents/ipc'

@Entity()
export class NoteInfoModel implements NoteInfo {
  @PrimaryGeneratedColumn('uuid')
  uuid!: string

  @Column()
  title!: string

  @Column('datetime')
  lastEditTime!: Date
}



repositoryクラスの生成

Repositoryパターンを使用してDB操作を行います。Repositoryパターンは、DB操作の抽象化と、データの永続化(保存、取得、更新、削除など)を一元的に管理することで、仮にDBの仕様変更が発生したとしても、ロジックへの影響を切り離すことが可能です。

https://qiita.com/mikesorae/items/ff8192fb9cf106262dbf

src/main/repository/noteInfoRepository.ts
export const getNoteInfo = async (uuid: string): Promise<NoteInfoModel> => {
  const connection = await Database.createConnection()
  return await connection
    .getRepository(NoteInfoModel)
    .createQueryBuilder('noteInfo')
    .where({ uuid })
    .getOneOrFail()
}

export const readNotesInfo = async (): Promise<NoteInfoModel[]> => {
  const connection = await Database.createConnection()
  return await connection
    .getRepository(NoteInfoModel)
    .createQueryBuilder('noteInfo')
    .select()
    .getMany()
}

/* 以下省略 */

上記ファイルはTypeORMのQueryBuilderを使用して、全レコードの取得や読み込みを行なっています。

https://qiita.com/taisuke-j/items/001dfaa8b61649601d73

https://typeorm.io/select-query-builder

TypeORMではDBを操作する方法としてQueryBuilderの他に、EntityManagerRepositoryがあります。

EntityManagerは全てのエンティティに対してDB操作を行うためのツールであり、トランザクションやエンティティの管理を統一的に行う場合に使用され、Repositoryは特定のエンティティに対してデータベース操作を行うためのツールです。QueryBuilderは生のSQLに近く、JOIN、WHERE条件、集計関数、サブクエリなどを用いた複雑なDB操作が可能です。

ツール 目的 利点 使用例
QueryBuilder 複雑なSQLクエリの構築 柔軟なクエリ構築、詳細なSQL制御 複雑なJOINやWHERE、集計関数を使ったクエリ
EntityManager 全エンティティに対する統一的な操作 全エンティティへのアクセス、トランザクション管理 複数エンティティを扱う操作、トランザクション処理
Repository 特定のエンティティに特化したデータベース操作 簡潔な操作、エンティティに特化したメソッドが用意されている 特定のエンティティの取得、保存、削除


whereの第二引数にパラメータのオブジェクトを渡すことで対象の値のエスケープを行なっています。

src/main/repository/noteInfoRepository.ts
.where('uuid = :id', { id: uuid })



contextBridge

contextBridgeを設定します。ElectronのcontextBridgeは、メインプロセスとレンダラープロセス間で、双方向で同期されたブリッジを作成することにより安全にデータをやり取りすることが可能です。Electronのメインプロセスは Node.js 環境で実行され、ファイルシステムや OS とのやり取りが可能であり、Node.jsの機能をそのままレンダラープロセスに提供すると、XSS(クロスサイトスクリプティング)攻撃などのセキュリティ面でリスクが生じます。その為、Electronでは、Node.js の機能に直接アクセスするのを避け、代わりにcontextBridgeを使って安全なAPIを提供するようになっています。

src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'

/** type */
import { ERROR_MASSAGE } from '../main/contents/enum'
import { CreateNote, DeleteNote, GetNote, ReadNote, WriteNote } from '@main/contents/ipc'

if (!process.contextIsolated) {
  // コンテキストが分離されていない場合
  throw new Error(ERROR_MASSAGE.MUST_USE_CONTEXT_ISOLATION)
}

const getNotes = async (): Promise<ReturnType<GetNote>> => {
  return await ipcRenderer.invoke('getNotes')
}

/** 省略 */

// contextBridgeに露出させるAPIを定義
export interface IElectronAPI {
  getNotes: typeof getNotes
  createNote: typeof createNote
  deleteNote: typeof deleteNote
  readNote: typeof readNote
  writeNote: typeof writeNote
}

try {
  // Docs: https://electronjs.org/docs/api/context-bridge
  contextBridge.exposeInMainWorld('electron', {
    getNotes,
    createNote,
    deleteNote,
    readNote,
    writeNote
  })
} catch (error) {
  console.error(error)
}

TypeScriptを使用してElectronのcontextBridgeを作成する場合は、APIの型を追加する必要があり、宣言ファイルを使用して型を拡張していきます。

https://www.electronjs.org/docs/latest/tutorial/context-isolation/#usage-with-typescript



/**
 * ファイル新規作成
 */
ipcMain.handle(
  'createNote',
  async (_, filename: string = '新規ノート'): Promise<NoteInfo | undefined> => {
    const newNote: NoteInfo = {
      uuid: uuidv7(),
      title: filename,
      content: WELCOME.WELCOME_NOTE_CONTENT,
      lastEditTime: new Date() // デフォルトの最終編集日時
    }
    const [__, saveNoteError] = await handleError(saveNoteInfo(newNote))

    if (saveNoteError) {
      logger(LOG_LEVEL.ERROR, `saveNoteInfo Error: ${saveNoteError}`)
    }

    logger(LOG_LEVEL.INFO, `saving note: ${filename}`)

    return newNote
  }
)

/**
 * ファイル削除
 */
ipcMain.handle('deleteNote', async (_, filename: string, uuid: string): Promise<boolean> => {
  const { response } = await dialog.showMessageBox({
    type: DIALOG_TYPE.WARNING as DialogValue,
    title: 'ノート削除',
    message: `${filename} を削除しますか?`,
    buttons: ['削除', 'キャンセル'], // 0:削除, 1:キャンセル
    defaultId: DIALOG_DEFAULT_ID,
    cancelId: DIALOG_CANCEL_ID
  })

  if (response === DIALOG_CANCEL_ID) {
    console.info(INFO_MASSAGE.NOTE_CANCELED)
    return false
  }

  const [__, deleteFileError] = await handleError(deleteNoteInfo(uuid))

  if (deleteFileError) {
    logger(LOG_LEVEL.ERROR, `DeleteNote Error: ${deleteFileError}`)
    return false
  }
  logger(LOG_LEVEL.INFO, `Deleting note: ${filename}`)

  return true
})
/** 省略 */

Electron ではメインプロセスとレンダラープロセスの連携にIPC通信を利用しており、メインプロセスの受信・送信にipcMainモジュールを使用します。

https://www.electronjs.org/ja/docs/latest/api/ipc-main

https://www.electronjs.org/ja/docs/latest/tutorial/ipc


ある程度バックエンドの記載が終わった為、ここでnpm run devを実施すると以下のエラーが発生しました。

App threw an error during load
ColumnTypeUndefinedError: Column type for NoteInfoModel#title is not defined and cannot be guessed. Make sure you have turned on an "emitDecoratorMetadata": true option in tsconfig.json.
Also make sure you have imported "reflect-metadata" on top of the main entry file in your application (before any entity imported).
If you are using JavaScript instead of TypeScript you must explicitly provide a column type.

https://github.com/typeorm/typeorm/issues/2897

issueを確認したところ、@Column() → @Column('text', { nullable: true })にする必要があったようなので以下の通り修正します。

  @Column('text', { nullable: false })
  title!: string

再度、npm run devを実行し、DBが正常に作成されていればバックエンドの実装は終了です。



最後に

ここまで読んでくださりありがとうございました。色々と長くなりそうなのでフロントエンド編は別途記載予定です。



Arsaga Developers Blog

Discussion