React × Electron × TypeORMで作成するMarkdownアプリ ~ バックエンド編 ~
はじめに
YoutubeでElectronを使用してマークダウンを実装するという動画を見かけ、丁度実務でElectronを触り始めたので、動画を参考にしながら色々と実装してみることにしました。
バックエンドのDB操作はTypeORMを使用し、フロント側はReactとJotaiを使用してます。主にバックエンド側のElectron × TypeORMで実装した内容について共有して行きたいと思います。今回実装した内容は以下リポジトリに格納しています。
バージョン情報
本実装のライブラリ、言語などのバージョン情報です。
ライブラリ・言語 | バージョン |
---|---|
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へのアクセスがメイン
Electronについては以下のZennBookの解説が分かりやすいです。
TypeORM
DBのテーブル操作に使用するTypeScriptで記載するORMライブラリです。DBのテーブルやビューを、プログラム内のクラスやオブジェクトにマッピングし、SQLを直接書かずにデータベース操作が可能です。
環境構築
まず初めにElectron × React × TypeScriptの環境を構築する為に、create-electronを使用します。今回はReactを使用する為--template
にreact
を指定します。
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
内にmain
とrenderer
ファイルがそれぞれ作成されます。npm run dev
でアプリが立ち上がればインストール成功です。
またElectron をホットリロード(開発中にコードを変更した場合、直ぐに反映され、アプリを再起動することなく、リアルタイムな動作確認が可能)してくれるツールをインストールしておきます。
npm install --save-dev electron-reload
tsconfig.jsonの設定
tsconfig.jsonファイルを設定します。tsconfig.node.json
とtsconfig.web.json
が存在しますが、Electronではメインプロセス(Node.js環境)とレンダラープロセス(Webブラウザ環境)のコードが含まれる為、両方の設定が必要になります。
{
"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(ソースコードに変更があった場合、差分のみブラウザに適用してくれる機能)を使用し、メインプロセスの場合は、ソースコードとプリロードスクリプトが直接バンドルされます。
vite.config.ts
でmain
、preload
、renderer
を個別に設定を行います。
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
に記載していきます。
// 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
でメインプロセスとレンダラープロセスのコンテキストを分離します。
型定義の追加
今回作成するnoteの型を定義します。
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を使用します。
エラーハンドリング
utilsにエラーハンドリング用の関数を作成しておきます。APIの処理で失敗すれば、エラーを返却してくれる為、エラーハンドリングが容易になります。
/**
* エラーハンドリング用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を導入します。
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
}
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// 他の設定は適宜追加
}
}
tsconfig.json
ファイルに以下の設定を追加し、デコレーターを有効にします。
公式ドキュメントに従いつつ、初期化処理を記載します。DB未接続の時のみ、DB接続を行います。
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接続する処理をメインプロセスに追加します。
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は立ち上げ方が異なるとのことで以下の記事が参考になりました。
Entityを追加
DBのエンティティを追記します。uuidをprimary key
として設定します。
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の仕様変更が発生したとしても、ロジックへの影響を切り離すことが可能です。
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
を使用して、全レコードの取得や読み込みを行なっています。
TypeORMではDBを操作する方法としてQueryBuilder
の他に、EntityManager
やRepository
があります。
EntityManager
は全てのエンティティに対してDB操作を行うためのツールであり、トランザクションやエンティティの管理を統一的に行う場合に使用され、Repository
は特定のエンティティに対してデータベース操作を行うためのツールです。QueryBuilder
は生のSQLに近く、JOIN、WHERE条件、集計関数、サブクエリなどを用いた複雑なDB操作が可能です。
ツール | 目的 | 利点 | 使用例 |
---|---|---|---|
QueryBuilder | 複雑なSQLクエリの構築 | 柔軟なクエリ構築、詳細なSQL制御 | 複雑なJOINやWHERE、集計関数を使ったクエリ |
EntityManager | 全エンティティに対する統一的な操作 | 全エンティティへのアクセス、トランザクション管理 | 複数エンティティを扱う操作、トランザクション処理 |
Repository | 特定のエンティティに特化したデータベース操作 | 簡潔な操作、エンティティに特化したメソッドが用意されている | 特定のエンティティの取得、保存、削除 |
whereの第二引数にパラメータのオブジェクトを渡すことで対象の値のエスケープを行なっています。
.where('uuid = :id', { id: uuid })
contextBridge
contextBridge
を設定します。ElectronのcontextBridgeは、メインプロセスとレンダラープロセス間で、双方向で同期されたブリッジを作成することにより安全にデータをやり取りすることが可能です。Electronのメインプロセスは Node.js 環境で実行され、ファイルシステムや OS とのやり取りが可能であり、Node.jsの機能をそのままレンダラープロセスに提供すると、XSS(クロスサイトスクリプティング)攻撃などのセキュリティ面でリスクが生じます。その為、Electronでは、Node.js の機能に直接アクセスするのを避け、代わりにcontextBridge
を使って安全なAPIを提供するようになっています。
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の型を追加する必要があり、宣言ファイルを使用して型を拡張していきます。
/**
* ファイル新規作成
*/
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
モジュールを使用します。
ある程度バックエンドの記載が終わった為、ここで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.
issueを確認したところ、@Column() → @Column('text', { nullable: true })
にする必要があったようなので以下の通り修正します。
@Column('text', { nullable: false })
title!: string
再度、npm run dev
を実行し、DBが正常に作成されていればバックエンドの実装は終了です。
最後に
ここまで読んでくださりありがとうございました。フロントエンド編はZenn Bookで執筆しました。
ご興味のある方はご覧ください。
Discussion