1. はじめに
筆者は普段はSalesforceエンジニアで、業務上のプログラミング経験はApexとLWC (JavaScript, HTML)のみです。Salesforce独自のエコシステムに引きこもらず、モダンな技術で保守性/拡張性の高いアプリを作れるようになりたいと考え、Electronで日記アプリを習作しました。
Electronを選んだのは以下の2つの理由です。
- フロントエンド / バックエンドが一気通貫で勉強できる
- サーバー不要で自分も使用可能なデスクトップアプリが手軽にビルド可能
UIにはイマドキのReactを使い、フロントエンド / バックエンドはどちらもTypeScriptで書いています。ほぼすべてのツールが初見だったため、大枠が完成するまでに学習込みで3ヶ月程度でした。1
clocで計測したところ設定ファイルとプログラムを含めて2100ステップくらいです2。
目次
2. 作成したアプリケーション
- exeファイルから日記アプリが起動
- 日記の見出し一覧を表示し、選択すると日記を開く
- 開いた日記のCRUDが可能 (SQLiteにより永続化)
- タブの一部に位置づけており、他機能も随時追加可能 3
- ソースコードはGitHubに公開しています
3. 本プロジェクトで実現したこと
3.1 主な技術スタック
- TypeScript
- 型安全性の向上
- Electron
- デスクトップアプリの基盤
- electron-reload
- レンダラープロセスのホットリロード実現
- React
- コンポーネントベースのUI構築
- Material-UI
- いい感じのUIデザインをお手軽に
- fp-ts
- TypeScriptでの関数型プログラミング4
- better-sqlite3
- SQLiteによる軽量なDB管理モジュール
- TSyringe
- TypeScriptのDIコンテナ
- Jest
- 単体テストツール
- ESLint, Prettier
- リンタ、フォーマッタ
3.2 設計・開発プロセス
- 軽量DDD的パターン
- Entity, ValueObject, Repository, Command Object, DTO
- モジュラーでスケーラブルなディレクトリ構成
- DI , DIコンテナ
- テスト自動化
- 関数型プログラミングの一部
- Result型、Option型、Do記法
4. 技術的詳細
4.1 ディレクトリ構造
src\以下はElectronの3機能および型定義ディレクトリを反映しています。
- メインプロセス (バックエンド。main。CommonJS)
- レンダラープロセス (フロントエンド。renderer。ES Modules)
- プリロードスクリプト (バックエンドとフロントエンドの橋渡し。preload)
- 複数の機能をまたぐ型定義 (types)
src
├── main
│ ├── feature
│ │ └── diaryApp
│ │ ├── application
│ │ │ └── appServicies
│ │ │ └── diaryApplicationService.ts
│ │ └── diaryAPI.ts
│ ├── diContainer.ts
│ └── main.ts
├── preload
│ └── features
│ └── diaryAppAPI.ts
├── renderer
│ ├── app
│ │ ├── App.tsx
│ │ └── diContainer.ts
│ └── features
│ ├── DiaryApp
│ │ └── API
│ │ └── diaryAppServiceElectron.ts
│ └── components
│ ├── ApplicationBar.tsx
│ └── EditorArea.tsx
└── types
└── electron-api.d.ts
各機能の直下はfeaturesディレクトリが配置され、機能ごとの縦割り分割を強制します。今後機能を追加していくときはこの直下に新しいディレクトリを足します。
メインプロセスは軽量DDDのパターンに合わせてリポジトリ、アプリケーションサービス、ドメインに分割しています。
レンダラープロセスの日記機能は、全体の大枠、テキストフォーム、日記選択のドロワーメニューに3分割しています。外部から値を取得するAPIも用意しています。
4.2 開発環境とビルド設定
プロジェクト全体のビルド設定はwebpack.config.jsで管理しています。
メインプロセス/レンダラープロセス/プリロードスクリプトはそれぞれ異なるエントリーポイントを持ち、モジュールの形式 (ComonJS, ECMAScript)や必要なプラグインも異なります。TypeScriptからのトランスパイルには3種別々の設定を与えており、tsconfigファイルも3通り作りました。
...
const mainConfig = {
entry: './src/main/main.ts',
target: 'electron-main',
...
}
const renderConfig = {
entry: './src/renderer/index.tsx',
target: 'electron-renderer',
...
}
const preloadConfig = {
target: 'electron-preload',
entry: './src/preload/preload.ts',
...
}
module.exports = [mainConfig, renderConfig, preloadConfig]
package.jsonを凝ってみました。
{
...
"main": "./dist/main/main.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci",
"test:clearCache": "jest --clearCache",
"start": "npm run build && electron .",
"build": "cross-env NODE_ENV=production webpack --mode production",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,md}\"",
"validate": "npm run lint && npm run test",
"pack": "electron-builder --dir",
"dist": "electron-builder",
"test-electron": "mocha test/electron/**/*.test.js",
"test-electron:watch": "mocha --watch",
"test-electron:coverage": "nyc mocha",
"dev:electron": "cross-env NODE_ENV=development webpack --mode development && concurrently \"webpack --mode development --watch\" \"electron .\"",
"postinstall": "patch-package && electron-builder install-app-deps"
},
...
}
- 開発時用ビルド / 本番用ビルドをcross-envで使い分け
- ホットリロード5
- リンタ / フォーマッタの設定
- テストの設定
4.3 React によるUI設計
ReactでコンポーネントベースのUIを作成しました。日記コンポーネントは以下3つに分かれます。
- 全体の大枠。日記アプリにおけるグローバルな状態を管理。
- 編集用テキスト欄
- 日記一覧ドロワーメニュー
単一責任の原則に従いつつ再利用性を高め、保守性を向上させます。
import ...
import { container } from '../../app/diContainer'
import { DiaryAppServiceI } from './API/diaryAppServiceI'
import { DiaryDTO } from '../../../types/diaryApp'
import { Summary } from '../../../types/diaryApp'
import { DrawerMenu } from './components/DrawerMenu'
import { EditorArea } from './components/EditorArea'
const App: React.FC = () => {
const diaryAppService = container.resolve<DiaryAppServiceI>('diaryAppService')
const [summaryList, setSummaryList] = useState<Summary[]>([])
const [nowEditingDiary, setNowEditingDiary] = useState<DiaryDTO>({
id: undefined,
title: '',
content: '',
createdAt: undefined,
updatedAt: undefined,
})
// 初期描画時にサマリーリスト、初期編集を取得
useEffect(() => {
const fetchData = async () => {
const summaryList = await diaryAppService.getAllDiarySummary()
setSummaryList(summaryList)
if (summaryList.length > 0) {
const diary = await diaryAppService.getDiary(summaryList[0].id.value)
if (diary != undefined) {
setNowEditingDiary(diary)
} else {
console.error('The first diary is undefined')
}
}
}
fetchData()
return () => {
console.log('cleanup')
}
}, [])
const [drawerOpen, setDrawerOpen] = React.useState<boolean>(true)
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen)
}
return (
<Box display='flex' position='relative'>
<CssBaseline />
<DrawerMenu
drawerOpen={drawerOpen}
toggleDrawer={toggleDrawer}
summaryList={summaryList}
nowEditingDiary={nowEditingDiary}
setNowEditingDiary={setNowEditingDiary}
setSummaryList={setSummaryList}
/>
<EditorArea
drawerOpen={drawerOpen}
toggleDrawer={toggleDrawer}
nowEditingDiary={nowEditingDiary}
setNowEditingDiary={setNowEditingDiary}
setSummaryList={setSummaryList}
/>
</Box>
)
}
export default App
LWCと似てはいますがいくつか違いもありました。
- tsxファイル一つにHTMLもJavaScriptも全部かけるので関心が分散しない
- 同一tsxファイルに複数のコンポーネントが定義可能なので、必要に応じて段階的にコンポーネント分割ができる
- 学習リソースが豊富 ! (←これが一番大きいかも)
Material-UI を使用することで、それっぽい画面を低コストで作れました。
ありがたい。
4.4 リポジトリとDIコンテナ
ドメインとリポジトリ,メインプロセスとレンダラープロセスなど、機能間の繋ぎ目はインターフェースで抽象化しています。
依存する実装はTSyringeのDIコンテナで中央管理することで、今後の永続化方式の変更を容易にしたり、テスタビリティを向上させています。
export { DiaryAppServiceI }
import { DiaryDTO, SavedDiaryDTO, Summary } from '../../../../types/diaryApp'
interface DiaryAppServiceI {
init(): Promise<void>
getAllDiarySummary(): Promise<Summary[]>
getDiary(id: string): Promise<DiaryDTO | undefined>
createDiary(diary: DiaryDTO): Promise<DiaryDTO>
updateDiary(diary: SavedDiaryDTO): Promise<SavedDiaryDTO>
deleteDiary(id: string): Promise<DiaryDTO>
sendHello(message: string): Promise<string>
}
export { DiaryAppServiceElectron }
import { DiaryAppServiceI } from './diaryAppServiceI'
import { DiaryDTO, SavedDiaryDTO, Summary } from '../../../../types/diaryApp'
class DiaryAppServiceElectron implements DiaryAppServiceI {
async init(): Promise<void> {
return await window.diaryAPI.init()
}
async getAllDiarySummary(): Promise<Summary[]> {
return await window.diaryAPI.getAllDiarySummary()
}
async getDiary(id: string): Promise<SavedDiaryDTO | undefined> {
return await window.diaryAPI.getDiary({ id })
}
async createDiary(diary: DiaryDTO): Promise<SavedDiaryDTO> {
return await window.diaryAPI.createDiary(diary)
}
async updateDiary(diary: SavedDiaryDTO): Promise<SavedDiaryDTO> {
return await window.diaryAPI.updateDiary(diary)
}
async deleteDiary(id: string): Promise<SavedDiaryDTO> {
return await window.diaryAPI.deleteDiary({ id })
}
async sendHello(message: string): Promise<string> {
return await window.diaryAPI.sendHello(message)
}
}
import 'reflect-metadata'
import { container } from 'tsyringe'
import { DiaryAppServiceElectron } from '../features/DiaryApp/API/diaryAppServiceElectron'
container.register('diaryAppService', {
useClass: DiaryAppServiceElectron,
})
export { container }
4.5 関数型プログラミングの導入
TypeScriptは高水準の型機能をもつ言語ですが、今ひとつ不満点もあります。
- エラーがスローされるので、関数の型定義からエラー処理の必要性が判断できない
- ユニオン型はあれどモナドでないので型チェックがしばしば冗長。
- 関数合成ができない
そこで、TypeScriptでも本格的な関数型プログラミングを行うための専用モジュールとして、fp-ts / io-ts があります。6
たとえば、fp-tsでは、エラーを含む処理はEither型 (エラーがLeft,正常な戻り値がRightに来る型)となりエラーが返ることが型定義で明示されます。正常な戻り値を取り出すには型チェックが必須なため、try-catchを設定し忘れることが構文論上不可能になります。一見複雑に見えますが、fp-ts-contribを導入すればモナドを利用したDo記法もサポートするので記述も簡潔です78。記法についてはユニオン型にない強みかなと思います。
本プロジェクトでは、EntityやValueObjectのバリデーションやSQLiteとのやり取りでEither型 (非同期はTaskEither型)を導入しています。TaskEither.TryCatch() はエラーをスローする関数をラップして、TaskEitherを返すように変えてくれるため、fp-tsのモジュールでなくても関数型を使用可能になります。
import ...
import * as TE from 'fp-ts/lib/TaskEither.js'
import * as O from 'fp-ts/lib/Option.js'
import * as E from 'fp-ts/lib/Either.js'
import { pipe } from 'fp-ts/lib/function.js'
class DiaryRepositorySQlite implements DiaryRepositoryI {
...
findById(id: Id): TE.TaskEither<Error, O.Option<Article>> {
return TE.tryCatch(
async () => {
const row = this.db()
.prepare('SELECT * FROM diaries WHERE id = ?')
.get(id.value)
if (!row) {
return O.none
}
const articleEither = this.toArticle(row)
if (E.isLeft(articleEither)) {
throw articleEither.left
}
return O.some(articleEither.right)
},
(e: any) =>
e instanceof Error
? e
: new Error(
'Unknown error occurred while finding article. : ' + String(e)
)
)
}
...
}
モナド (EitherやOption)に包めば「既存のレコードが見つかった場合は更新処理をする」といった処理もDo記法で一本道にきれいに記述できます。
関数合成は pipe や flow で自然に書けます。
import ...
import { DiaryRepositoryI } from '../../repository/diaryRepositoryI'
import { injectable } from 'tsyringe'
import * as TE from 'fp-ts/lib/TaskEither.js'
import * as E from 'fp-ts/lib/Either.js'
import { ArticleUpdateCommand } from '../command/articleUpdateCommand'
import * as O from 'fp-ts/lib/Option.js'
import { Do } from 'fp-ts-contrib/lib/Do.js'
import { pipe } from 'fp-ts/lib/function.js'
@injectable()
class DiaryApplicationService {
repository: DiaryRepositoryI
...
updateArticle(command: ArticleUpdateCommand): TE.TaskEither<Error, Article> {
return Do(TE.Monad)
.bind(
'newArticle', TE.fromEither(
Article.of(command.title, command.content, new Id(command.id))
)
)
.bind('articleOption', this.repository.findById(new Id(command.id)))
.bindL('article', ({ articleOption, newArticle }) => {
return pipe(
articleOption,
O.fold(
() => TE.left(new Error('Article not found')),
article => this.repository.update(newArticle)
)
)
})
.bindL('updatedArticle', ({ article }) => {
return this.repository.update(article)
})
.return(({ updatedArticle }) => updatedArticle)
}
...
}
また、ランタイムの型チェックとしてio-tsも導入しています。TypeScriptの型チェックはランタイム時点では消去されますが、たとえば、DBから取得した生の行データがただしく型の条件を満たすかを確認したいときにはio-tsが役立ちます。生データにanyを導入せずにすみます。
// 記事情報をランタイムに型チェックするヘルパーメソッド
const StringOrNull = withFallback(t.string, '')
const ArticleCodec = t.type({
title: t.string,
content: StringOrNull,
id: t.number,
created_at: t.string,
updated_at: t.string,
})
// データベースの行を型チェックして、失敗したらError 、成功したら記事を返す
private toArticle(row: unknown): E.Either<Error, Article> {
if (!ArticleCodec.is(row)) {
return E.left(new Error('Invalid row data.'))
}
return Article.of(
row.title,
row.content,
new Id(String(row.id)),
new Date(row.created_at),
new Date(row.updated_at)
)
}
4.6 ElectronのIPC通信
メインプロセスとレンダラープロセスの間のセキュアな通信を実現します。メインプロセス側からは特定の通信のみを許容することで、画面側からはバックエンドを自由に操作できないようにします。
import { contextBridge, ipcRenderer } from 'electron'
import { DiaryDTO } from '../../types/diaryApp'
export function diaryAppAPIPreload() {
contextBridge.exposeInMainWorld('diaryAPI', {
...
getAllDiarySummary: () => {
try {
return ipcRenderer.invoke('get-all-diary-summary')
} catch (e: any) {
throwError(e)
}
},
getDiary: (arg: { id: string }) => {
try {
return ipcRenderer.invoke('get-diary', arg)
} catch (e: any) {
throwError(e)
}
},
...
})
}
export const diaryAPI = (ipcMain: IpcMain): void => {
...
ipcMain.handle('get-all-diary-summary', async (event, args) => {
const app = new DiaryApplicationService()
const summaries = await app.getAllSummary()()
if (E.isLeft(summaries)) {
throw summaries.left
} else {
return summaries.right
}
})
ipcMain.handle(
'get-diary',
async (event, args: { id: string }): Promise<SavedDiaryDTO | undefined> => {
const app = new DiaryApplicationService()
const command = new ArticleFindCommand(args.id)
const diary = await app.findArticleById(command)()
if (E.isLeft(diary)) {
throw diary.left
} else {
return O.isSome(diary.right)
? articleToSavedDto(diary.right.value)
: undefined
}
}
)
...
}
5. 最後に
5.1 今後の展望
現状、ほとんど最低限度のメモアプリです。機能面でも学習面でも今後やりたいことはたくさんあります。下記はほんの一部です。
- 機能追加したい!
- markdownの編集・プレビュー機能
- 記事のソート、検索
- 自動テストを追加したい!
- より網羅的な単体テスト
- E2Eテストの導入
- もう少し複雑なDBスキーマを設計したい!
- ログ機能を充実させたい!
5.2 感想
- DBからUIまで沢山の機能と設計を駆使できて楽しかった!
- モダンな技術を一気に学べて目的達成できた!
- 今後ももっと色々遊びたい!
- Qiitaの記事も初めて投稿できてよかった!
-
2024/05 ~ 2024/08 ↩
-
.gitignoreでトラックから排除するようなファイル (node_modulesやdist, coverageなど)、およびpackage-lock.jsonを除いています。 ↩
-
現状、他のタブはお遊び的コンポーネントが配置されていますが、今後は様々な本格的な機能を追加したいです。 ↩
-
後で知りましたが、上位互換のEffect-TSを使うべきらしいです
https://qiita.com/konkon-T/items/0bcb8cb1fd8f075156da ↩ -
ホットリロード導入のためelectron-reloadを導入が大変でした。実は導入したelectron-reloadのバージョンが私のビルド設定と相性が悪く、解決のためnode_modulesの中身を開発者が直接書き換えています。しかし、node_modulesは.gitignoreに設定しているため、そのままではGitHub上には手動変更が反映されません。patch-moduleをインストールして書き換え内容をパッチとして保存することで、node_modulesの手動書き換えもGitHub上でトラックできるようにしています。 ↩
-
いっそElmやPureScriptの使用も検討しましたが、TypeScriptの人気に惹かれました(ミーハー) ↩
-
実はネイティブにbind, bindTo でDo記法が使えるみたいです。
https://dev.to/e_ntyo/fp-ts-2-8-0-bind-bindto-85l ↩ -
さすがにHaskellほど簡潔じゃないですけどこれだけでもめっちゃ嬉しい。ApexにもOptionとかmap, filterとか来ないかなぁ。 ↩