カレンダーアプリケーションを作成してみた(工夫点と改善点)
なにか作りたいなという思いと学習を兼ねてカレンダーWebアプリケーションを作成してみた。
基本的なカレンダーアプリケーションが持つべき機能は実装したつもり。具体的には、カレンダーやここの予定の作成・編集・削除・共有など。
以下でサンプルを公開しているので、ぜひ触ってみてください。
そして、このnoteでは、実際に作成した際の工夫点や改善点をまとめています。
作成物一覧
リポジトリ
サンプルアプリケーション
実際のサンプルイメージ
工夫点
工夫点1:依存性の分離を意識したアーキテクチャ設計
バックエンド(Golang)は最初にアーキテクチャ設計で頭を悩ませた。
レイヤードアーキテクチャを基盤にすることだけ決めていた
サービスのロジック部は可能な限り外部環境に依存しないように実装したい。そうすることで変更に強くなる。
とは言ったものの、DBがサービスに依存する状況は通常作れない。
なので、Dependency Injectionを用いて、サービスがDB側の実装に依存しないように設計することに。
最終的には、サービス部に変更を加えずにデータストアやエンドポイントを切り替えられることを目的としてアーキテクチャ設計を行い実装した。
これにより、DBが変わっても変更範囲はリポジトリ周りのみで抑えられるし、エンドポイントをREST APIからGraphQLに切り替える際もサービスには変更を加える必要がない。
というわけで、上記のアーキテクチャ設計に準じて下記のようにディレクトリを分割し実装した。
以下はカレンダーサービスのディレクトリ構成。(一部省略している)
├── app # Service Endopoints
│ └── rest # REST API
│ └── calendar # Calendar Service Endpoint
│
└── calendar # Calendar Service
├── model # Data Model
├── repository # DB
│ └── store # RDB and NoSQL
└── service # Service Process
/app
エンドポイントを実装するディレクトリ。
今回は「/app/rest」でREST APIエンドポイント用の実装のみをしている。
別のエンドポイントが必要となった場合には「/app/graphql」や「/app/cli」といった形でディレクトリを作成してGraphQLやCLIのエンドポイントを実装することを想定している。
/calendar
カレンダーサービスを実装するディレクトリ。
内部で以下のようにディレクトリを分けている。
/calendar/model
サービスが扱うデータモデルが定義されていて、基本的にこのデータモデルでデータがやり取りされる。
エンドポイントやDBとデータをやり取りする際にはそれ専用のモデルを利用。
(現在はディレクトリを作るまでもなかったなと反省中。。それについては後ほど)
/calendar/repository
データストアを実装するディレクトリ。
今回は「/calendar/repository/store」でPostgreSQLを利用したデータストアを実装している。
開発中、DB環境を用意するまではインメモリでデータを管理する「/calendar/repository/inmem」を作成し利用していた。
サービスとデータストアはインターフェイスを介して接続しているので、インメモリからPostgreSQLを利用したデータストアへの移行は最小限の変更で可能だった。
(テストコードだけは少々改修する必要があった。。これは反省点として次回以降のアーキテクチャ設計に活かしたい)
/calendar/service
サービスを実装しているディレクトリ。
ここに実際のサービスの処理(カレンダーや予定の作成など)が実装されている。
サービスは外部のエンドポイントやデータストアの実装ではなく、インターフェイスに依存するように設計している。そのため、DBの変更などが容易になっている。
各依存関係は図示するとこんな感じ。レイヤードアーキテクチャなので各機能ごとに分離していて、必要な部分にのみ依存している。
各機能が一方向依存するように設計しているので、互いのインターフェイスに変更がない限りは、各機能固有の変更は容易。
例えば、DBをPostgreSQLからMySQLに変更したいや、NoSQLにしたいなどの場合は、「/calendar/repository」のみに変更を加えることで実現可能。この際にサービス機能部やエンドポイント部の実装に影響は出ないですむ。
同様にエンドポイントの改修や追加をしたい場合も「/app/calendar」にのみ変更を加えればよい。
少し前にも書いたが、データストアに関してはDBが用意できるまではインメモリデータストアを作成し代わりに利用していた。
インメモリからPostgreSQLに切り替える際も、この依存関係構成のおかげでサービス部に影響なく切り替えることができている。(インターフェイスに依存させる重要性を実感できた)
上記のような思想でアーキテクチャとディレクトリ設計を行った結果、バックエンドの全体のディレクトリ構成は下記になった。
.
├── app
│ └── rest
│ ├── auth # API of Authenticaiton Service
│ ├── calendar # API of Calendar Service
│ ├── middlewares
│ └── testutils
├── auth # Authentication Service
│ ├── model
│ ├── repository
│ │ ├── inmem
│ │ └── store
│ └── service
├── calendar # Calendar Service
│ ├── model
│ ├── repository
│ │ ├── inmem
│ │ └── store
│ └── service
├── logging
└── model
├── ctx
└── error
工夫点2:トランザクション処理
DBに対する操作において、トランザクション処理が不要ならばなるべく使いたくないと考えていた。そのためにはトランザクション処理を行う場合と行わない場合で実装を分ける必要がある。
でも、通常時は「Find()」でトランザクション処理中は「FindTx()」みたいに呼び出す関数を分けたくない。(わがままかもしれない。。)
ようは可能な限り呼び出す側は呼び出し先の都合に依存したくないなと。
なので、各関数が個別で呼ばれた場合とトランザクション処理の中で呼ばれた場合で、自動的に処理が分かれるように実装した。
実装方法はシンプルで、まず最初にトランザクション処理を開始すると「sql.Tx」を構造体に埋め込む。
func (m *store) BeginTX() error {
tx, err := m.db.Begin()
if err != nil {
return cerror.NewInternalError(
err,
"failed to begin transaction",
)
}
m.tx = tx
return nil
}
実際の実装は以下にある。
そして、実際にSQLを実行する処理では、構造体に「sql.Tx」が埋め込まれているかでトランザクション処理中なのかを判断し処理を分岐している。
下記は実際に処理を分岐する処理の一例。いろんな箇所を省略しているが、トランザクション処理の有無で処理を分けているのはわかってもらえると思う。
type calendarRepo struct {
db *sql.DB
tx *sql.Tx
}
// 引数や処理の詳細は省略
func (r *calendarRepo) Find() {
// 省略
if r.tx != nil {
// トランザクション処理中に呼び出された
rows, err = r.tx.Query(query, id)
} else {
rows, err = r.db.Query(query, id)
}
// 省略
}
上記のように内部でトランザクション処理中か判断させる事により、呼び出す側(サービス機能部)ではトランザクション処理中かどうか気にすることなくレシーバを呼び出すことができる。
トランザクション処理を行う場合
repo.BeginTx()
repo.Calendar().Find() // トランザクション処理中に呼び出し
repo.Commit()
トランザクション処理を行わない場合
repo.Calendar().Find() // トランザクション処理外で呼び出し
工夫点3:データの特性を意識したデータストアの選択
今回はHerokuへのアップを前提としていたので、RDBならPostgreSQLでNoSQLならRedisにしようと最初から考えていた。(学習用なので、無料で利用したい。。)
最終的は基本的に大部分のデータをRDBで管理することとし、ユーザのセッション情報のみRedisで管理することとした。
データストアの選択基準としては、可能な限りNoSQLで管理して処理速度などを上げておきたかったが、保管するデータの特性からNoSQLが向かない場合ももちろんある。
そのため、データの特性を考えて以下の基準で判断することとした。これらのうち一つ以上がYesの場合は、RDBを利用することとした。
データストアの判断基準
・データの生存期間が長いか
・トランザクション処理が必要か
・消えたら問題があるか
結果サービスで扱うデータの大部分をRDBで管理することとした。
セッション情報だけはデータの特性が以下となるので、今回はNoSQLで管理することとした。
セッション情報の特性
・データの生存期間がたかだか1ヶ月
・トランザクション処理はいらない
・最悪消えても再ログインしてもらえば良い
工夫点4:カレンダーサイズを各デバイスごとに調整
今回のアプリのUIはVuetifyを利用して作成している。もちろんカレンダーの部分もカレンダー用のコンポーネントを利用させてもらっている。
カレンダー用のコンポーネントを利用していた際に困ったのは、カレンダーの縦方向のサイズが閲覧端末に応じてうまく変動させることができなかったこと。
サンプルコードを参考にしながら作成していたのだが、縦方向の調整に関しては自動でサイズ調整する術を見つけられなかった。。
というわけで、閲覧端末の高さを取得してカレンダーの縦サイズを算出することにした。実装方法は以下のようにした。シンプルに「window.innerHeight」で値を取得し、そこから適切な値を引いたものとした。
mounted() {
this.calendarHeight = window.innerHeight - 150;
}
カレンダーのサイズは以下のようにして調節している。
「<v-sheet>」を用いてサイズ調整し、その上にカレンダーを表示することによりサイズを制御している。
<v-sheet :height="calendarHeight">
<v-calendar />
</v-sheet>
少しハマったのは、「window.innerHeight」の取得タイミングの問題。
「data()」ではなく「mount()」で取得しないとうまく取得できなかった。
原因はちゃんと確認していないがVue.jsの処理タイミングの違いによるものなはず。
工夫点5:各ゲッターのキャッシュの有効活用
Web側のデータの保持にはVuexを利用していた。
Vuexのデータを取得するパターンは色々あるのだけど、可能な限りキャッシュを効かせて無駄な計算処理などは発生させたくないなと考えた。
基本的にはゲッターを利用していればデフォルトでキャッシュが効くのだけど、引数を用いると途端に効かなくなるので、工夫する必要があった。
というわけで、データの取得パターン別にキャッシュが効くかを整理して、キャッシュが利用できるように実装した。
Vuexからのデータ取得パターン
1. Vuexのデータをそのまま参照する場合
2. Vuexのデータを加工して参照する場合
3. Vuexの引数ありのゲッターで参照する場合
1の場合は、特に気にせず直接参照していた。
(例:「this.$store.state.calendars.calendars」)
2の場合は、Vuexゲッターがデフォルトでキャッシュが効くのでそれを利用して参照していた。
3の場合は、Vuexのゲッターのキャッシュが効かない。なので、キャッシュを効かせたい場合はキャッシュ可能なcomputedで定義した関数から呼び出すなど工夫していた。
改善点
ここまで書いたように色々考え、工夫して実装してみたが、まだまだ考えが浅かったなと思う。
作成中や完成したあとも「こうしておけばよかったなぁ」と反省点がいっぱい出てきた。現状思いついた改善点は下記のようなものがある。
改善点1:リクエストパラメータのバリデート
リクエストパラメータのバリデートは専用のパッケージを利用するべきだった。
現状は自作したバリデート処理が多く、処理の流れや関数の見通しが悪化しているように感じるので、容易にバリデートできるパッケージを利用して見通しよく実装したい。(例えば以下とか)
改善点2:トランザクション処理のコミットやロールバックの自動化
トランザクション処理の終了時に呼び出すべきコミットやロールバック処理の呼び出しを絶対に忘れないような設計にしておくべきだったかもしれない。
現在は「BegiTx()」でトランザクション処理を開始したあと様々なDB操作用関数を呼び出し、最後に「Commit()」などを呼び出す必要がある。もし途中で処理に失敗した場合は「Rollback()」を呼び出す必要がある。
このために、エラー処理する部分には毎度ロールバック処理を組み込まなければならない。とても冗長な処理の流れになり、忘れてしまいそうになる。
もう少しスマートに処理をしたいので、「defer」などを有効活用し、トランザクションの開始のみ意識すれば良いような設計にするべきだったと考えている。
改善点3:カスタムエラーを利用したエラーハンドリング
サービス内部ではカスタムエラーを利用してエラーハンドリングを行っている。
これは、DBのエラーを種別などに応じてカスタムエラーでラップして伝搬していくことで、エンドポイントでどのエラーで応答すればよいかなどを判断しやすくすることを目的としていた。
しかし、カスタムエラーでラップすることを意識しすぎた結果、無駄なラップや、ハンドリングする適切な階層がわからなくなったためにエラーが適切に処理されていないケースが出てしまった。
適切な階層でエラーハンドリングを実施することを意識し次回以降は実装できるようにしたい。
だがまずは、カスタムエラーを用いない場合のエラーハンドリング(特にDBエラー時などのエンドポイントでの応答の選択処理など)についてさまざまなリポジトリなどを読んで勉強したいと考えている。
改善点4:DB処理の単体テスト
今回は単体テストはエンドポイントレベルでしか実装していない。(もはや単体テストと言っていいのか。。結合テストかな?)
一応このレベルでしか実装していないのには理由がある。
まず、エンドポイントに対するテストを実施した場合、サービス機能部も一緒にテストする構成にしている。
というよりもエンドポイントのみのテストに意味がないと考えている。
(もしやっても、ハンドラーが適切な関数を呼び出すかのチェックにしかならないので)
また、なぜサービス機能部単位でテストしていないのかというと、単体テストを実施してサービス機能部の実装の正常性を保証したところであまり嬉しくない考えているから。
正常性を保証することで、エンドポイントの切替時に検証範囲を新規構築分のみに絞れるのだが、エンドポイント部とサービス部を同時にテストする場合は意味がないかなと。(この構成の場合は2重テストになって無駄)
というわけで、エンドポイントレベルでしか実装していない。
しかし、DBを操作する部分に関しては別途単体テストを実施したほうが良かったかなと考えている。
というのも、サービスのテスト時に完全なテスト用のDBが用意できない場合や、処理時間の関係でテスト時にはDBを使いたくない場合もあるかなと思ったから。
テスト時にはDB操作用の関数はモック関数を仕込んでテストする可能性もあるわけで、その場合は現テストではDB操作部を一緒にテストできない。
また、DB操作は重いのでエンドポイントレベルのテストでは時間短縮のためにもモック関数を埋め込んで実施のほうが良かったかなぁと考えている。
となると、DB操作部は別途テストする構成のほうがいいかなと考えた。
ここに関しては、様々なプロジェクトの実装例などを学んでどうすべきか判断していきたい。
改善点5:フロントエンドのテストコード
フロントエンドのテストに関しては今回実施していないので、次回は実施したい。
各コンポーネントが想定動作をしているかの単体テストぐらいは実施したいかなと考えている。
ただ、フロントエンドに関しては完全に勉強不足なので、まず実際のテスト構成やどのような観点でテストすべきかなどを学ぶところから始めていきたい。
改善点6:コンポーネント分割の基準
コンポーネントの分割の基準が曖昧なので、それをしっかりと定義し実装していけばよかったなと反省している。
基本的には、そのコンポーネント単体で与えられた目的(カレンダーコンポーネントならカレンダーの表示)を達成できるように分けているつもりではあるが、少々曖昧になっていると感じるところがあった。
また、そのように分割した際に複数箇所で同じような実装が現れてしまったりしたので、それを新たなコンポーネントに切り出すなど、まだまだやれるべきことがあったなと感じている。
改善点7:フロントエンド側のエラー処理やリトライ処理
実はフロントエンド側でAPI通信に失敗した場合の再送処理やエラーポップアップが実装されていない。。
なので、一時的なネットワークエラーで通信が切られると、その時実施していた「予定作成」などが通知無しで失敗するだけであり、ユーザの判断でまたやり直して貰う必要がある。
これはアプリとしてはちょっと問題なので、次回以降は通信に対するエラーハンドリングやその際の再送処理やユーザの行動を促すようなポップアップなどを入れていきたいと考えている。
おわりに
ここまで読んでくださった方、ありがとうございます。
Go言語が好き&アーキテクチャ設計について考えたりすることが好きな関係上、バックエンド側のアーキテクチャ設計について工夫したことがメインになりました。
また、自分が作成したものに対して改めて考えを整理してみると、まだまだ考えが足りない箇所が多いなと再実感することになりました。
今回挙げた改善点に関しては、様々なリポジトリのコードリーディングするなどして、自分なりの解答を見つけ、次回以降の作成に活かしていきたい。
あとは、普段こんなに文章書くことがないので、なかなかに時間がかかりました。。。
この記事が気に入ったらサポートをしてみませんか?