55
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ElectronAdvent Calendar 2015

Day 14

Electronアプリ開発で設計に失敗した話

Last updated at Posted at 2015-12-13

この投稿は Electron Advent Calendar 2015 の14日目の記事です。

最近、実験的にElectronを使ってOSX用のアプリケーションを開発していました。
あくまでも次に作るためのアプリケーションのための実験なので

  • 地雷は喜んで踏みつける
  • 技術的におもしろいことをやる
  • 失敗しても泣かない

の精神です。

ちなみに結果は見事に失敗してます。

設計

大枠として上記のような設計にしようとしていました。
主に3つのレイヤーを作り、それぞれはEventEmitterでやりとりを行います。

1. Applicationレイヤー

アプリケーション全体を管理するレイヤーです。
もろもろの初期化や、Electronの独自部分(メニューとか)の操作を行います。

2. Domainレイヤー

DDD的な意味のドメインレイヤーです。

ここで言うユースケースはクリーンアーキテクチャで言うユースケースというより、レイヤードアーキテクチャのアプリケーションレイヤーの方が近い感じです。

3. Windowレイヤー

各ウィンドウで表示するビューを管理するレイヤーです。

今回は手馴れたフレームワーク+新しいおもちゃという訳で、angularjs+babel+angular-decoratorsという構成でやりました。

@Component({ selector : 'tasks-content-list' })
@View({ template: `
  <style scoped>
  #tasks-content-list > ul {
    width: 100%;
  }
  .media {
    width: 100%;
    height: 90px;
    margin: 0;
    padding: 5px;
    border-bottom: 1px solid #C9C9CA;
    cursor: default;
  }
  </style>
  <ul class="media-list">
    <li class="media" ng-repeat="task in tasksContentList.tasks | toArray | orderBy:'-dueDate' track by task.id" ng-click="tasksContentList.select(task)">
      <div class="media-left">
        <img ng-src="{{task.assignor.avatarThumbnailImageUrl}}" class="media-object img-circle" />
      </div>
      <div class="media-body">
        <div class="media-heading">
          <strong class="media-due-date">{{task.dueDate | date:'MM/dd/yyyy'}}</strong>
          <small class="media-assigner pull-right">in {{task.room.name}}</small>
        </div<
        <div class="clearfix"></div>
        <div class="media-description">{{task.description | removeTag}}</div>
      </div>
    </li>
  </ul>
`})
@Inject('$scope', SelectedTaskHandler, TaskProvider.$get)
class TasksContentListComponent{

  constructor($scope, selectedTaskHandler, tasks) {
    this.$scope = $scope;
    this.selectedTaskHandler = selectedTaskHandler;

    this.tasks = tasks;

    this.selectedTaskHandler.save(this.tasks[Object.keys(this.tasks)[0]]);
  }

  @apply
  async select(task) {
    await this.selectedTaskHandler.removeAll();
    await this.selectedTaskHandler.save(task);
  }
}

electron関係ないですが上のようか書き方になっています。
angularjsっぽさは全然ありませんが、angularjsの芋くさい部分を除去できたので書いてて楽しかったです。

サイクルとしては

  1. ビューからバインドされたコンポーネントを実行
  2. コンポーネントは対応するハンドラーを実行
  3. ハンドラーはEventEmitterでイベントを発火
  4. EventEmitterからイベントの結果を受け取り、データソースの値を変更
  5. ビューはバインドされたデータソースの変更を検知して描画(angularjsの変更検知に丸投げ)

というサイクルで画面の描画をやっています。
ちなみにですが、それぞれPromiseで非同期に実行結果を取ったり、データソースは単純なObjectやArrayなのでfluxとはまた別な組み方にしています。

4. EventEmitter

各レイヤーを超えるためのEventEmitterです。
プロセスを超えてやりとりする必要があるため、ipcをラップして相互にやりとりを行えるようにしています。

基本的にアプリケーション、ウィンドウレイヤーからイベントを発火し、ドメインレイヤーでイベントを処理して元のレイヤーに結果をイベントで戻します。

ここは真面目に作り始めるとイベント数が増えるのが目に見えていたので、下記のようなイベントクラスを作り、イベントクラスでバインド、イベントのオブジェクトで発火できるようにしています。

export default class LoginEvent extends Event {
  token: string;
  version: string;
  accountId: number;
  roomId: number;

  constructor(token, version, accountId, roomId) {
    super({token, version, accountId, roomId});
  }
}

イベントを文字列で管理するのは難しいし、プロセスまたぐのでSymbolは使えないしということで、型でイベントの発火と受信をする方式にしています。

失敗した話

というわけで前振りも終わったので本題の失敗した話です。
失敗して頭抱えてる状態なので、解決策的な良い話は一切ありません。

ドメイン貧血症

Electronは関係ありませんが問題の一つとしてドメインレイヤーが薄く、切り出した旨味が全くないということがありました。
ユースケースは受け取ったイベントをエンティティに変換して、リポジトリに保管するだけで特にやることもなく、エンティティ自体に振る舞いを持つこともありませんでした。

恐らく、今回作成したアプリケーションがシンプルなCRUDしか行わなかったところが敗因かなぁと思っています。
とはいえもう少し複雑になことをしたとしても、振る舞いがUIと密結合になりやすいので、似たような処理をUIとドメインに書いてツラい未来しか見えません。
クライアントサイドDDD難しいです。

ただ、この構成も良いところはあって、ユーザーの近いところにもう一つサーバーを作るイメージで、APIやローカルのストレージ操作などを隠蔽してくれるレイヤーができたのは良かったなと思っています。
特にUI部分ではとりあえずイベントを送れば良しなにデータを永続化できるというのはUI部分をスリムに保つのに役立ちました。

複数のレイヤーをまたがるイベント

問題の一つで複数のレイヤーにまたがるイベントを表現する方法がこの設計ではありませんでした。
例えばログインイベントでは面倒なことになっていて

  1. ウィンドウ1でログイン処理を実行(webview)
  2. ログインに成功したらログインイベントを発火
  3. アプリケーションレイヤーでログインイベントを受け取る
  4. アプリケーションレイヤーで受け取ったログインイベントからセッション情報を抜き出し、セッションストアイベントを発火
  5. ドメインレイヤーのユースケースでセッションを保存
  6. アプリケーションレイヤーで結果を受け取り、ウィンドウ2を生成、ウィンドウ1を削除

と酷いことになってしまいました。
1のログイン処理がwebviewを使ってセッション情報を無理やり強奪するためにドメインレイヤーのユースケースを挟むわけにいかなかったり、ウィンドウの生成、削除はアプリケーションレイヤーでの役割ということで、ドメインレイヤーを守るにはこうするしかなかった感じです。

発火したイベントは元の発火元に戻るとした失敗例でした。
たぶん、発火したイベントの結果を別のレイヤーでも受信できるとすれば上手くやれそうな気はしますが、そうするとイベントがどこで受信されるのか考える必要が出てきて複雑になりそうな雰囲気です。

アプリケーションレイヤーからウィンドウレイヤーにまたがるイベント

次に困ったのがメニューのクリックなど、アプリケーションレイヤーから今フォーカスしているウィンドウにイベントを発火する方法がこの設計ではありませんでした。

基本的には複数のレイヤーをまたがるイベントの問題と同様で上記のように、イベントを別のレイヤーで受信できるとすれば上手くやれそうです。
ただし、データをリロードするイベントなど、メニューとウィンドウから同じイベントを実行することが多いので、イベントの発火元が複数になってしまう嫌な感じもあります。
出来ればイベントを発火するのも、受信するのも一箇所にしたいです。

とはいえ、上記のようにしてしまうとイベントがどこからどこへ飛ぶのかわかりずらくなるツラみがあります。
あっちが立てばこっちが立たずということでどうするべきか悩ましいです。

おわりに

最初の設計思想としては悪くない方向だと思ったのですが、ものの見事に失敗してしまいました。
3つのレイヤーに分けることで、Electron独自の要素はアプリケーションレイヤーに、ビジネス要件的なところはドメインレイヤーに、UI的な要素はウィンドウレイヤーに分けれて開発しやすくなると思ったのですが・・・。

という訳で今回の設計は窓から投げ捨てました。
次は別のアプローチで開発してみようかなと思います。

Electronでこんなアプリケーションを作ったという事例が良く出てきたので、そろそろこういう設計で成功したみたいな話を聞きたいところです。

55
53
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?